From 44c33617fa3971fa6c7bbf97be924479eab29f81 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=BE=84=E6=BD=AD?= Date: Thu, 28 Aug 2025 19:26:21 +0800 Subject: [PATCH] feat(ai-proxy): add OpenRouter provider support (#2823) --- plugins/wasm-go/extensions/ai-proxy/README.md | 61 + .../wasm-go/extensions/ai-proxy/README_EN.md | 61 + .../ai-proxy/claude-message-api.yaml | 3734 +++++++++++++++++ plugins/wasm-go/extensions/ai-proxy/main.go | 152 +- .../extensions/ai-proxy/provider/claude.go | 264 +- .../ai-proxy/provider/claude_to_openai.go | 739 +++- .../provider/claude_to_openai_test.go | 727 ++++ .../extensions/ai-proxy/provider/minimax.go | 4 +- .../extensions/ai-proxy/provider/model.go | 7 + .../ai-proxy/provider/openrouter.go | 117 + .../extensions/ai-proxy/provider/provider.go | 2 + 11 files changed, 5684 insertions(+), 184 deletions(-) create mode 100644 plugins/wasm-go/extensions/ai-proxy/claude-message-api.yaml create mode 100644 plugins/wasm-go/extensions/ai-proxy/provider/claude_to_openai_test.go create mode 100644 plugins/wasm-go/extensions/ai-proxy/provider/openrouter.go diff --git a/plugins/wasm-go/extensions/ai-proxy/README.md b/plugins/wasm-go/extensions/ai-proxy/README.md index e3d4b98a9..fb2557db4 100644 --- a/plugins/wasm-go/extensions/ai-proxy/README.md +++ b/plugins/wasm-go/extensions/ai-proxy/README.md @@ -173,6 +173,10 @@ Groq 所对应的 `type` 为 `groq`。它并无特有的配置字段。 Grok 所对应的 `type` 为 `grok`。它并无特有的配置字段。 +#### OpenRouter + +OpenRouter 所对应的 `type` 为 `openrouter`。它并无特有的配置字段。 + #### 文心一言(Baidu) 文心一言所对应的 `type` 为 `baidu`。它并无特有的配置字段。 @@ -948,6 +952,63 @@ provider: } ``` +### 使用 OpenAI 协议代理 OpenRouter 服务 + +**配置信息** + +```yaml +provider: + type: openrouter + apiTokens: + - 'YOUR_OPENROUTER_API_TOKEN' + modelMapping: + 'gpt-4': 'openai/gpt-4-turbo-preview' + 'gpt-3.5-turbo': 'openai/gpt-3.5-turbo' + 'claude-3': 'anthropic/claude-3-opus' + '*': 'openai/gpt-3.5-turbo' +``` + +**请求示例** + +```json +{ + "model": "gpt-4", + "messages": [ + { + "role": "user", + "content": "你好,你是谁?" + } + ], + "temperature": 0.7 +} +``` + +**响应示例** + +```json +{ + "id": "gen-1234567890abcdef", + "object": "chat.completion", + "created": 1699123456, + "model": "openai/gpt-4-turbo-preview", + "choices": [ + { + "index": 0, + "message": { + "role": "assistant", + "content": "你好!我是一个AI助手,通过OpenRouter平台提供服务。我可以帮助回答问题、协助创作、进行对话等。有什么我可以帮助你的吗?" + }, + "finish_reason": "stop" + } + ], + "usage": { + "prompt_tokens": 12, + "completion_tokens": 46, + "total_tokens": 58 + } +} +``` + ### 使用自动协议兼容功能 插件现在支持自动协议检测,可以同时处理 OpenAI 和 Claude 两种协议格式的请求。 diff --git a/plugins/wasm-go/extensions/ai-proxy/README_EN.md b/plugins/wasm-go/extensions/ai-proxy/README_EN.md index ecf0d6b18..5c052f382 100644 --- a/plugins/wasm-go/extensions/ai-proxy/README_EN.md +++ b/plugins/wasm-go/extensions/ai-proxy/README_EN.md @@ -144,6 +144,10 @@ For Groq, the corresponding `type` is `groq`. It has no unique configuration fie For Grok, the corresponding `type` is `grok`. It has no unique configuration fields. +#### OpenRouter + +For OpenRouter, the corresponding `type` is `openrouter`. It has no unique configuration fields. + #### ERNIE Bot For ERNIE Bot, the corresponding `type` is `baidu`. It has no unique configuration fields. @@ -894,6 +898,63 @@ provider: } ``` +### Using OpenAI Protocol Proxy for OpenRouter Service + +**Configuration Information** + +```yaml +provider: + type: openrouter + apiTokens: + - 'YOUR_OPENROUTER_API_TOKEN' + modelMapping: + 'gpt-4': 'openai/gpt-4-turbo-preview' + 'gpt-3.5-turbo': 'openai/gpt-3.5-turbo' + 'claude-3': 'anthropic/claude-3-opus' + '*': 'openai/gpt-3.5-turbo' +``` + +**Example Request** + +```json +{ + "model": "gpt-4", + "messages": [ + { + "role": "user", + "content": "Hello, who are you?" + } + ], + "temperature": 0.7 +} +``` + +**Example Response** + +```json +{ + "id": "gen-1234567890abcdef", + "object": "chat.completion", + "created": 1699123456, + "model": "openai/gpt-4-turbo-preview", + "choices": [ + { + "index": 0, + "message": { + "role": "assistant", + "content": "Hello! I am an AI assistant powered by OpenRouter. I can help answer questions, assist with creative tasks, engage in conversations, and more. How can I assist you today?" + }, + "finish_reason": "stop" + } + ], + "usage": { + "prompt_tokens": 12, + "completion_tokens": 35, + "total_tokens": 47 + } +} +``` + ### Using Auto Protocol Compatibility The plugin now supports automatic protocol detection, capable of handling both OpenAI and Claude protocol format requests simultaneously. diff --git a/plugins/wasm-go/extensions/ai-proxy/claude-message-api.yaml b/plugins/wasm-go/extensions/ai-proxy/claude-message-api.yaml new file mode 100644 index 000000000..17b59182d --- /dev/null +++ b/plugins/wasm-go/extensions/ai-proxy/claude-message-api.yaml @@ -0,0 +1,3734 @@ +paths: + path: /v1/messages + method: post + servers: + - url: https://api.anthropic.com + request: + security: [] + parameters: + path: {} + query: {} + header: + anthropic-beta: + schema: + - type: array + items: + allOf: + - type: string + required: false + title: Anthropic-Beta + description: >- + Optional header to specify the beta version(s) you want to use. + + + To use multiple betas, use a comma separated list like + `beta1,beta2` or specify the header multiple times for each + beta. + anthropic-version: + schema: + - type: string + required: true + title: Anthropic-Version + description: >- + The version of the Anthropic API you want to use. + + + Read more about versioning and our version history + [here](https://docs.anthropic.com/en/api/versioning). + x-api-key: + schema: + - type: string + required: true + title: X-Api-Key + description: >- + Your unique API key for authentication. + + + This key is required in the header of all API requests, to + authenticate your account and access Anthropic's services. Get + your API key through the + [Console](https://console.anthropic.com/settings/keys). Each key + is scoped to a Workspace. + cookie: {} + body: + application/json: + schemaArray: + - type: object + properties: + model: + allOf: + - description: >- + The model that will complete your prompt. + + + See + [models](https://docs.anthropic.com/en/docs/models-overview) + for additional details and options. + examples: + - claude-sonnet-4-20250514 + maxLength: 256 + minLength: 1 + title: Model + type: string + messages: + allOf: + - description: >- + Input messages. + + + Our models are trained to operate on alternating `user` + and `assistant` conversational turns. When creating a new + `Message`, you specify the prior conversational turns with + the `messages` parameter, and the model then generates the + next `Message` in the conversation. Consecutive `user` or + `assistant` turns in your request will be combined into a + single turn. + + + Each input message must be an object with a `role` and + `content`. You can specify a single `user`-role message, + or you can include multiple `user` and `assistant` + messages. + + + If the final message uses the `assistant` role, the + response content will continue immediately from the + content in that message. This can be used to constrain + part of the model's response. + + + Example with a single `user` message: + + + ```json + + [{"role": "user", "content": "Hello, Claude"}] + + ``` + + + Example with multiple conversational turns: + + + ```json + + [ + {"role": "user", "content": "Hello there."}, + {"role": "assistant", "content": "Hi, I'm Claude. How can I help you?"}, + {"role": "user", "content": "Can you explain LLMs in plain English?"}, + ] + + ``` + + + Example with a partially-filled response from Claude: + + + ```json + + [ + {"role": "user", "content": "What's the Greek name for Sun? (A) Sol (B) Helios (C) Sun"}, + {"role": "assistant", "content": "The best answer is ("}, + ] + + ``` + + + Each input message `content` may be either a single + `string` or an array of content blocks, where each block + has a specific `type`. Using a `string` for `content` is + shorthand for an array of one content block of type + `"text"`. The following input messages are equivalent: + + + ```json + + {"role": "user", "content": "Hello, Claude"} + + ``` + + + ```json + + {"role": "user", "content": [{"type": "text", "text": + "Hello, Claude"}]} + + ``` + + + See + [examples](https://docs.anthropic.com/en/api/messages-examples) + for more input examples. + + + Note that if you want to include a [system + prompt](https://docs.anthropic.com/en/docs/system-prompts), + you can use the top-level `system` parameter — there is no + `"system"` role for input messages in the Messages API. + + + There is a limit of 100,000 messages in a single request. + items: + $ref: '#/components/schemas/InputMessage' + title: Messages + type: array + container: + allOf: + - anyOf: + - type: string + - type: 'null' + description: Container identifier for reuse across requests. + title: Container + max_tokens: + allOf: + - description: >- + The maximum number of tokens to generate before stopping. + + + Note that our models may stop _before_ reaching this + maximum. This parameter only specifies the absolute + maximum number of tokens to generate. + + + Different models have different maximum values for this + parameter. See + [models](https://docs.anthropic.com/en/docs/models-overview) + for details. + examples: + - 1024 + minimum: 1 + title: Max Tokens + type: integer + mcp_servers: + allOf: + - description: MCP servers to be utilized in this request + items: + $ref: '#/components/schemas/RequestMCPServerURLDefinition' + maxItems: 20 + title: Mcp Servers + type: array + metadata: + allOf: + - $ref: '#/components/schemas/Metadata' + description: An object describing metadata about the request. + service_tier: + allOf: + - description: >- + Determines whether to use priority capacity (if available) + or standard capacity for this request. + + + Anthropic offers different levels of service for your API + requests. See + [service-tiers](https://docs.anthropic.com/en/api/service-tiers) + for details. + enum: + - auto + - standard_only + title: Service Tier + type: string + stop_sequences: + allOf: + - description: >- + Custom text sequences that will cause the model to stop + generating. + + + Our models will normally stop when they have naturally + completed their turn, which will result in a response + `stop_reason` of `"end_turn"`. + + + If you want the model to stop generating when it + encounters custom strings of text, you can use the + `stop_sequences` parameter. If the model encounters one of + the custom sequences, the response `stop_reason` value + will be `"stop_sequence"` and the response `stop_sequence` + value will contain the matched stop sequence. + items: + type: string + title: Stop Sequences + type: array + stream: + allOf: + - description: >- + Whether to incrementally stream the response using + server-sent events. + + + See + [streaming](https://docs.anthropic.com/en/api/messages-streaming) + for details. + title: Stream + type: boolean + system: + allOf: + - anyOf: + - type: string + - items: + $ref: '#/components/schemas/RequestTextBlock' + type: array + description: >- + System prompt. + + + A system prompt is a way of providing context and + instructions to Claude, such as specifying a particular + goal or role. See our [guide to system + prompts](https://docs.anthropic.com/en/docs/system-prompts). + examples: + - - text: Today's date is 2024-06-01. + type: text + - Today's date is 2023-01-01. + title: System + temperature: + allOf: + - description: >- + Amount of randomness injected into the response. + + + Defaults to `1.0`. Ranges from `0.0` to `1.0`. Use + `temperature` closer to `0.0` for analytical / multiple + choice, and closer to `1.0` for creative and generative + tasks. + + + Note that even with `temperature` of `0.0`, the results + will not be fully deterministic. + examples: + - 1 + maximum: 1 + minimum: 0 + title: Temperature + type: number + thinking: + allOf: + - description: >- + Configuration for enabling Claude's extended thinking. + + + When enabled, responses include `thinking` content blocks + showing Claude's thinking process before the final answer. + Requires a minimum budget of 1,024 tokens and counts + towards your `max_tokens` limit. + + + See [extended + thinking](https://docs.anthropic.com/en/docs/build-with-claude/extended-thinking) + for details. + discriminator: + mapping: + disabled: '#/components/schemas/ThinkingConfigDisabled' + enabled: '#/components/schemas/ThinkingConfigEnabled' + propertyName: type + oneOf: + - $ref: '#/components/schemas/ThinkingConfigEnabled' + - $ref: '#/components/schemas/ThinkingConfigDisabled' + tool_choice: + allOf: + - description: >- + How the model should use the provided tools. The model can + use a specific tool, any available tool, decide by itself, + or not use tools at all. + discriminator: + mapping: + any: '#/components/schemas/ToolChoiceAny' + auto: '#/components/schemas/ToolChoiceAuto' + none: '#/components/schemas/ToolChoiceNone' + tool: '#/components/schemas/ToolChoiceTool' + propertyName: type + oneOf: + - $ref: '#/components/schemas/ToolChoiceAuto' + - $ref: '#/components/schemas/ToolChoiceAny' + - $ref: '#/components/schemas/ToolChoiceTool' + - $ref: '#/components/schemas/ToolChoiceNone' + tools: + allOf: + - description: >- + Definitions of tools that the model may use. + + + If you include `tools` in your API request, the model may + return `tool_use` content blocks that represent the + model's use of those tools. You can then run those tools + using the tool input generated by the model and then + optionally return results back to the model using + `tool_result` content blocks. + + + There are two types of tools: **client tools** and + **server tools**. The behavior described below applies to + client tools. For [server + tools](https://docs.anthropic.com/en/docs/agents-and-tools/tool-use/overview\#server-tools), + see their individual documentation as each has its own + behavior (e.g., the [web search + tool](https://docs.anthropic.com/en/docs/agents-and-tools/tool-use/web-search-tool)). + + + Each tool definition includes: + + + * `name`: Name of the tool. + + * `description`: Optional, but strongly-recommended + description of the tool. + + * `input_schema`: [JSON + schema](https://json-schema.org/draft/2020-12) for the + tool `input` shape that the model will produce in + `tool_use` output content blocks. + + + For example, if you defined `tools` as: + + + ```json + + [ + { + "name": "get_stock_price", + "description": "Get the current stock price for a given ticker symbol.", + "input_schema": { + "type": "object", + "properties": { + "ticker": { + "type": "string", + "description": "The stock ticker symbol, e.g. AAPL for Apple Inc." + } + }, + "required": ["ticker"] + } + } + ] + + ``` + + + And then asked the model "What's the S&P 500 at today?", + the model might produce `tool_use` content blocks in the + response like this: + + + ```json + + [ + { + "type": "tool_use", + "id": "toolu_01D7FLrfh4GYq7yT1ULFeyMV", + "name": "get_stock_price", + "input": { "ticker": "^GSPC" } + } + ] + + ``` + + + You might then run your `get_stock_price` tool with + `{"ticker": "^GSPC"}` as an input, and return the + following back to the model in a subsequent `user` + message: + + + ```json + + [ + { + "type": "tool_result", + "tool_use_id": "toolu_01D7FLrfh4GYq7yT1ULFeyMV", + "content": "259.75 USD" + } + ] + + ``` + + + Tools can be used for workflows that include running + client-side tools and functions, or more generally + whenever you want the model to produce a particular JSON + structure of output. + + + See our + [guide](https://docs.anthropic.com/en/docs/tool-use) for + more details. + examples: + - description: Get the current weather in a given location + input_schema: + properties: + location: + description: The city and state, e.g. San Francisco, CA + type: string + unit: + description: >- + Unit for the output - one of (celsius, + fahrenheit) + type: string + required: + - location + type: object + name: get_weather + items: + oneOf: + - $ref: '#/components/schemas/Tool' + - $ref: '#/components/schemas/BashTool_20241022' + - $ref: '#/components/schemas/BashTool_20250124' + - $ref: '#/components/schemas/CodeExecutionTool_20250522' + - $ref: '#/components/schemas/ComputerUseTool_20241022' + - $ref: '#/components/schemas/ComputerUseTool_20250124' + - $ref: '#/components/schemas/TextEditor_20241022' + - $ref: '#/components/schemas/TextEditor_20250124' + - $ref: '#/components/schemas/TextEditor_20250429' + - $ref: '#/components/schemas/TextEditor_20250728' + - $ref: '#/components/schemas/WebSearchTool_20250305' + title: Tools + type: array + top_k: + allOf: + - description: >- + Only sample from the top K options for each subsequent + token. + + + Used to remove "long tail" low probability responses. + [Learn more technical details + here](https://towardsdatascience.com/how-to-sample-from-language-models-682bceb97277). + + + Recommended for advanced use cases only. You usually only + need to use `temperature`. + examples: + - 5 + minimum: 0 + title: Top K + type: integer + top_p: + allOf: + - description: >- + Use nucleus sampling. + + + In nucleus sampling, we compute the cumulative + distribution over all the options for each subsequent + token in decreasing probability order and cut it off once + it reaches a particular probability specified by `top_p`. + You should either alter `temperature` or `top_p`, but not + both. + + + Recommended for advanced use cases only. You usually only + need to use `temperature`. + examples: + - 0.7 + maximum: 1 + minimum: 0 + title: Top P + type: number + required: true + title: CreateMessageParams + requiredProperties: + - model + - messages + - max_tokens + additionalProperties: false + example: + max_tokens: 1024 + messages: + - content: Hello, world + role: user + model: claude-sonnet-4-20250514 + examples: + example: + value: + max_tokens: 1024 + messages: + - content: Hello, world + role: user + model: claude-sonnet-4-20250514 + codeSamples: + - lang: bash + source: |- + curl https://api.anthropic.com/v1/messages \ + --header "x-api-key: $ANTHROPIC_API_KEY" \ + --header "anthropic-version: 2023-06-01" \ + --header "content-type: application/json" \ + --data \ + '{ + "model": "claude-sonnet-4-20250514", + "max_tokens": 1024, + "messages": [ + {"role": "user", "content": "Hello, world"} + ] + }' + - lang: python + source: |- + import anthropic + + anthropic.Anthropic().messages.create( + model="claude-sonnet-4-20250514", + max_tokens=1024, + messages=[ + {"role": "user", "content": "Hello, world"} + ] + ) + - lang: javascript + source: |- + import { Anthropic } from '@anthropic-ai/sdk'; + + const anthropic = new Anthropic(); + + await anthropic.messages.create({ + model: "claude-sonnet-4-20250514", + max_tokens: 1024, + messages: [ + {"role": "user", "content": "Hello, world"} + ] + }); + response: + '200': + application/json: + schemaArray: + - type: object + properties: + id: + allOf: + - description: |- + Unique object identifier. + + The format and length of IDs may change over time. + examples: + - msg_013Zva2CMHLNnXjNJJKqJ2EF + title: Id + type: string + type: + allOf: + - const: message + default: message + description: |- + Object type. + + For Messages, this is always `"message"`. + enum: + - message + title: Type + type: string + role: + allOf: + - const: assistant + default: assistant + description: |- + Conversational role of the generated message. + + This will always be `"assistant"`. + enum: + - assistant + title: Role + type: string + content: + allOf: + - description: >- + Content generated by the model. + + + This is an array of content blocks, each of which has a + `type` that determines its shape. + + + Example: + + + ```json + + [{"type": "text", "text": "Hi, I'm Claude."}] + + ``` + + + If the request input `messages` ended with an `assistant` + turn, then the response `content` will continue directly + from that last turn. You can use this to constrain the + model's output. + + + For example, if the input `messages` were: + + ```json + + [ + {"role": "user", "content": "What's the Greek name for Sun? (A) Sol (B) Helios (C) Sun"}, + {"role": "assistant", "content": "The best answer is ("} + ] + + ``` + + + Then the response `content` might be: + + + ```json + + [{"type": "text", "text": "B)"}] + + ``` + examples: + - - text: Hi! My name is Claude. + type: text + items: + discriminator: + mapping: + code_execution_tool_result: >- + #/components/schemas/ResponseCodeExecutionToolResultBlock + container_upload: '#/components/schemas/ResponseContainerUploadBlock' + mcp_tool_result: '#/components/schemas/ResponseMCPToolResultBlock' + mcp_tool_use: '#/components/schemas/ResponseMCPToolUseBlock' + redacted_thinking: '#/components/schemas/ResponseRedactedThinkingBlock' + server_tool_use: '#/components/schemas/ResponseServerToolUseBlock' + text: '#/components/schemas/ResponseTextBlock' + thinking: '#/components/schemas/ResponseThinkingBlock' + tool_use: '#/components/schemas/ResponseToolUseBlock' + web_search_tool_result: >- + #/components/schemas/ResponseWebSearchToolResultBlock + propertyName: type + oneOf: + - $ref: '#/components/schemas/ResponseTextBlock' + - $ref: '#/components/schemas/ResponseThinkingBlock' + - $ref: '#/components/schemas/ResponseRedactedThinkingBlock' + - $ref: '#/components/schemas/ResponseToolUseBlock' + - $ref: '#/components/schemas/ResponseServerToolUseBlock' + - $ref: >- + #/components/schemas/ResponseWebSearchToolResultBlock + - $ref: >- + #/components/schemas/ResponseCodeExecutionToolResultBlock + - $ref: '#/components/schemas/ResponseMCPToolUseBlock' + - $ref: '#/components/schemas/ResponseMCPToolResultBlock' + - $ref: '#/components/schemas/ResponseContainerUploadBlock' + title: Content + type: array + model: + allOf: + - description: The model that handled the request. + examples: + - claude-sonnet-4-20250514 + maxLength: 256 + minLength: 1 + title: Model + type: string + stop_reason: + allOf: + - anyOf: + - enum: + - end_turn + - max_tokens + - stop_sequence + - tool_use + - pause_turn + - refusal + type: string + - type: 'null' + description: >- + The reason that we stopped. + + + This may be one the following values: + + * `"end_turn"`: the model reached a natural stopping point + + * `"max_tokens"`: we exceeded the requested `max_tokens` + or the model's maximum + + * `"stop_sequence"`: one of your provided custom + `stop_sequences` was generated + + * `"tool_use"`: the model invoked one or more tools + + * `"pause_turn"`: we paused a long-running turn. You may + provide the response back as-is in a subsequent request to + let the model continue. + + * `"refusal"`: when streaming classifiers intervene to + handle potential policy violations + + + In non-streaming mode this value is always non-null. In + streaming mode, it is null in the `message_start` event + and non-null otherwise. + title: Stop Reason + stop_sequence: + allOf: + - anyOf: + - type: string + - type: 'null' + default: null + description: >- + Which custom stop sequence was generated, if any. + + + This value will be a non-null string if one of your custom + stop sequences was generated. + title: Stop Sequence + usage: + allOf: + - $ref: '#/components/schemas/Usage' + description: >- + Billing and rate-limit usage. + + + Anthropic's API bills and rate-limits by token counts, as + tokens represent the underlying cost to our systems. + + + Under the hood, the API transforms requests into a format + suitable for the model. The model's output then goes + through a parsing stage before becoming an API response. + As a result, the token counts in `usage` will not match + one-to-one with the exact visible content of an API + request or response. + + + For example, `output_tokens` will be non-zero, even for an + empty string response from Claude. + + + Total input tokens in a request is the summation of + `input_tokens`, `cache_creation_input_tokens`, and + `cache_read_input_tokens`. + examples: + - input_tokens: 2095 + output_tokens: 503 + container: + allOf: + - anyOf: + - $ref: '#/components/schemas/Container' + - type: 'null' + default: null + description: >- + Information about the container used in this request. + + + This will be non-null if a container tool (e.g. code + execution) was used. + title: Message + examples: + - content: &ref_0 + - text: Hi! My name is Claude. + type: text + id: msg_013Zva2CMHLNnXjNJJKqJ2EF + model: claude-sonnet-4-20250514 + role: assistant + stop_reason: end_turn + stop_sequence: null + type: message + usage: &ref_1 + input_tokens: 2095 + output_tokens: 503 + requiredProperties: + - id + - type + - role + - content + - model + - stop_reason + - stop_sequence + - usage + - container + example: + content: *ref_0 + id: msg_013Zva2CMHLNnXjNJJKqJ2EF + model: claude-sonnet-4-20250514 + role: assistant + stop_reason: end_turn + stop_sequence: null + type: message + usage: *ref_1 + examples: + example: + value: + content: + - text: Hi! My name is Claude. + type: text + id: msg_013Zva2CMHLNnXjNJJKqJ2EF + model: claude-sonnet-4-20250514 + role: assistant + stop_reason: end_turn + stop_sequence: null + type: message + usage: + input_tokens: 2095 + output_tokens: 503 + description: Message object. + 4XX: + application/json: + schemaArray: + - type: object + properties: + error: + allOf: + - discriminator: + mapping: + api_error: '#/components/schemas/APIError' + authentication_error: '#/components/schemas/AuthenticationError' + billing_error: '#/components/schemas/BillingError' + invalid_request_error: '#/components/schemas/InvalidRequestError' + not_found_error: '#/components/schemas/NotFoundError' + overloaded_error: '#/components/schemas/OverloadedError' + permission_error: '#/components/schemas/PermissionError' + rate_limit_error: '#/components/schemas/RateLimitError' + timeout_error: '#/components/schemas/GatewayTimeoutError' + propertyName: type + oneOf: + - $ref: '#/components/schemas/InvalidRequestError' + - $ref: '#/components/schemas/AuthenticationError' + - $ref: '#/components/schemas/BillingError' + - $ref: '#/components/schemas/PermissionError' + - $ref: '#/components/schemas/NotFoundError' + - $ref: '#/components/schemas/RateLimitError' + - $ref: '#/components/schemas/GatewayTimeoutError' + - $ref: '#/components/schemas/APIError' + - $ref: '#/components/schemas/OverloadedError' + title: Error + type: + allOf: + - const: error + default: error + enum: + - error + title: Type + type: string + title: ErrorResponse + requiredProperties: + - error + - type + examples: + example: + value: + error: + message: Invalid request + type: invalid_request_error + type: error + description: >- + Error response. + + + See our [errors + documentation](https://docs.anthropic.com/en/api/errors) for more + details. + deprecated: false + type: path +components: + schemas: + APIError: + properties: + message: + default: Internal server error + title: Message + type: string + type: + const: api_error + default: api_error + enum: + - api_error + title: Type + type: string + required: + - message + - type + title: APIError + type: object + AuthenticationError: + properties: + message: + default: Authentication error + title: Message + type: string + type: + const: authentication_error + default: authentication_error + enum: + - authentication_error + title: Type + type: string + required: + - message + - type + title: AuthenticationError + type: object + Base64ImageSource: + additionalProperties: false + properties: + data: + format: byte + title: Data + type: string + media_type: + enum: + - image/jpeg + - image/png + - image/gif + - image/webp + title: Media Type + type: string + type: + const: base64 + enum: + - base64 + title: Type + type: string + required: + - data + - media_type + - type + title: Base64ImageSource + type: object + Base64PDFSource: + additionalProperties: false + properties: + data: + format: byte + title: Data + type: string + media_type: + const: application/pdf + enum: + - application/pdf + title: Media Type + type: string + type: + const: base64 + enum: + - base64 + title: Type + type: string + required: + - data + - media_type + - type + title: PDF (base64) + type: object + BashTool_20241022: + additionalProperties: false + properties: + cache_control: + anyOf: + - discriminator: + mapping: + ephemeral: '#/components/schemas/CacheControlEphemeral' + propertyName: type + oneOf: + - $ref: '#/components/schemas/CacheControlEphemeral' + - type: 'null' + description: Create a cache control breakpoint at this content block. + title: Cache Control + name: + const: bash + description: >- + Name of the tool. + + + This is how the tool will be called by the model and in `tool_use` + blocks. + enum: + - bash + title: Name + type: string + type: + const: bash_20241022 + enum: + - bash_20241022 + title: Type + type: string + required: + - name + - type + title: Bash tool (2024-10-22) + type: object + BashTool_20250124: + additionalProperties: false + properties: + cache_control: + anyOf: + - discriminator: + mapping: + ephemeral: '#/components/schemas/CacheControlEphemeral' + propertyName: type + oneOf: + - $ref: '#/components/schemas/CacheControlEphemeral' + - type: 'null' + description: Create a cache control breakpoint at this content block. + title: Cache Control + name: + const: bash + description: >- + Name of the tool. + + + This is how the tool will be called by the model and in `tool_use` + blocks. + enum: + - bash + title: Name + type: string + type: + const: bash_20250124 + enum: + - bash_20250124 + title: Type + type: string + required: + - name + - type + title: Bash tool (2025-01-24) + type: object + BillingError: + properties: + message: + default: Billing error + title: Message + type: string + type: + const: billing_error + default: billing_error + enum: + - billing_error + title: Type + type: string + required: + - message + - type + title: BillingError + type: object + CacheControlEphemeral: + additionalProperties: false + properties: + ttl: + description: |- + The time-to-live for the cache control breakpoint. + + This may be one the following values: + - `5m`: 5 minutes + - `1h`: 1 hour + + Defaults to `5m`. + enum: + - 5m + - 1h + title: Ttl + type: string + type: + const: ephemeral + enum: + - ephemeral + title: Type + type: string + required: + - type + title: CacheControlEphemeral + type: object + CacheCreation: + properties: + ephemeral_1h_input_tokens: + default: 0 + description: The number of input tokens used to create the 1 hour cache entry. + minimum: 0 + title: Ephemeral 1H Input Tokens + type: integer + ephemeral_5m_input_tokens: + default: 0 + description: The number of input tokens used to create the 5 minute cache entry. + minimum: 0 + title: Ephemeral 5M Input Tokens + type: integer + required: + - ephemeral_1h_input_tokens + - ephemeral_5m_input_tokens + title: CacheCreation + type: object + CodeExecutionToolResultErrorCode: + enum: + - invalid_tool_input + - unavailable + - too_many_requests + - execution_time_exceeded + title: CodeExecutionToolResultErrorCode + type: string + CodeExecutionTool_20250522: + additionalProperties: false + properties: + cache_control: + anyOf: + - discriminator: + mapping: + ephemeral: '#/components/schemas/CacheControlEphemeral' + propertyName: type + oneOf: + - $ref: '#/components/schemas/CacheControlEphemeral' + - type: 'null' + description: Create a cache control breakpoint at this content block. + title: Cache Control + name: + const: code_execution + description: >- + Name of the tool. + + + This is how the tool will be called by the model and in `tool_use` + blocks. + enum: + - code_execution + title: Name + type: string + type: + const: code_execution_20250522 + enum: + - code_execution_20250522 + title: Type + type: string + required: + - name + - type + title: Code execution tool (2025-05-22) + type: object + ComputerUseTool_20241022: + additionalProperties: false + properties: + cache_control: + anyOf: + - discriminator: + mapping: + ephemeral: '#/components/schemas/CacheControlEphemeral' + propertyName: type + oneOf: + - $ref: '#/components/schemas/CacheControlEphemeral' + - type: 'null' + description: Create a cache control breakpoint at this content block. + title: Cache Control + display_height_px: + description: The height of the display in pixels. + minimum: 1 + title: Display Height Px + type: integer + display_number: + anyOf: + - minimum: 0 + type: integer + - type: 'null' + description: The X11 display number (e.g. 0, 1) for the display. + title: Display Number + display_width_px: + description: The width of the display in pixels. + minimum: 1 + title: Display Width Px + type: integer + name: + const: computer + description: >- + Name of the tool. + + + This is how the tool will be called by the model and in `tool_use` + blocks. + enum: + - computer + title: Name + type: string + type: + const: computer_20241022 + enum: + - computer_20241022 + title: Type + type: string + required: + - display_height_px + - display_width_px + - name + - type + title: Computer use tool (2024-01-22) + type: object + ComputerUseTool_20250124: + additionalProperties: false + properties: + cache_control: + anyOf: + - discriminator: + mapping: + ephemeral: '#/components/schemas/CacheControlEphemeral' + propertyName: type + oneOf: + - $ref: '#/components/schemas/CacheControlEphemeral' + - type: 'null' + description: Create a cache control breakpoint at this content block. + title: Cache Control + display_height_px: + description: The height of the display in pixels. + minimum: 1 + title: Display Height Px + type: integer + display_number: + anyOf: + - minimum: 0 + type: integer + - type: 'null' + description: The X11 display number (e.g. 0, 1) for the display. + title: Display Number + display_width_px: + description: The width of the display in pixels. + minimum: 1 + title: Display Width Px + type: integer + name: + const: computer + description: >- + Name of the tool. + + + This is how the tool will be called by the model and in `tool_use` + blocks. + enum: + - computer + title: Name + type: string + type: + const: computer_20250124 + enum: + - computer_20250124 + title: Type + type: string + required: + - display_height_px + - display_width_px + - name + - type + title: Computer use tool (2025-01-24) + type: object + Container: + description: >- + Information about the container used in the request (for the code + execution tool) + properties: + expires_at: + description: The time at which the container will expire. + format: date-time + title: Expires At + type: string + id: + description: Identifier for the container used in this request + title: Id + type: string + required: + - expires_at + - id + title: Container + type: object + ContentBlockSource: + additionalProperties: false + properties: + content: + anyOf: + - type: string + - items: + discriminator: + mapping: + image: '#/components/schemas/RequestImageBlock' + text: '#/components/schemas/RequestTextBlock' + propertyName: type + oneOf: + - $ref: '#/components/schemas/RequestTextBlock' + - $ref: '#/components/schemas/RequestImageBlock' + type: array + title: Content + type: + const: content + enum: + - content + title: Type + type: string + required: + - content + - type + title: Content block + type: object + FileDocumentSource: + additionalProperties: false + properties: + file_id: + title: File Id + type: string + type: + const: file + enum: + - file + title: Type + type: string + required: + - file_id + - type + title: File document + type: object + FileImageSource: + additionalProperties: false + properties: + file_id: + title: File Id + type: string + type: + const: file + enum: + - file + title: Type + type: string + required: + - file_id + - type + title: FileImageSource + type: object + GatewayTimeoutError: + properties: + message: + default: Request timeout + title: Message + type: string + type: + const: timeout_error + default: timeout_error + enum: + - timeout_error + title: Type + type: string + required: + - message + - type + title: GatewayTimeoutError + type: object + InputMessage: + additionalProperties: false + properties: + content: + anyOf: + - type: string + - items: + discriminator: + mapping: + code_execution_tool_result: '#/components/schemas/RequestCodeExecutionToolResultBlock' + container_upload: '#/components/schemas/RequestContainerUploadBlock' + document: '#/components/schemas/RequestDocumentBlock' + image: '#/components/schemas/RequestImageBlock' + mcp_tool_result: '#/components/schemas/RequestMCPToolResultBlock' + mcp_tool_use: '#/components/schemas/RequestMCPToolUseBlock' + redacted_thinking: '#/components/schemas/RequestRedactedThinkingBlock' + search_result: '#/components/schemas/RequestSearchResultBlock' + server_tool_use: '#/components/schemas/RequestServerToolUseBlock' + text: '#/components/schemas/RequestTextBlock' + thinking: '#/components/schemas/RequestThinkingBlock' + tool_result: '#/components/schemas/RequestToolResultBlock' + tool_use: '#/components/schemas/RequestToolUseBlock' + web_search_tool_result: '#/components/schemas/RequestWebSearchToolResultBlock' + propertyName: type + oneOf: + - $ref: '#/components/schemas/RequestTextBlock' + description: Regular text content. + - $ref: '#/components/schemas/RequestImageBlock' + description: >- + Image content specified directly as base64 data or as a + reference via a URL. + - $ref: '#/components/schemas/RequestDocumentBlock' + description: >- + Document content, either specified directly as base64 + data, as text, or as a reference via a URL. + - $ref: '#/components/schemas/RequestSearchResultBlock' + description: >- + A search result block containing source, title, and + content from search operations. + - $ref: '#/components/schemas/RequestThinkingBlock' + description: A block specifying internal thinking by the model. + - $ref: '#/components/schemas/RequestRedactedThinkingBlock' + description: >- + A block specifying internal, redacted thinking by the + model. + - $ref: '#/components/schemas/RequestToolUseBlock' + description: A block indicating a tool use by the model. + - $ref: '#/components/schemas/RequestToolResultBlock' + description: A block specifying the results of a tool use by the model. + - $ref: '#/components/schemas/RequestServerToolUseBlock' + - $ref: '#/components/schemas/RequestWebSearchToolResultBlock' + - $ref: '#/components/schemas/RequestCodeExecutionToolResultBlock' + - $ref: '#/components/schemas/RequestMCPToolUseBlock' + - $ref: '#/components/schemas/RequestMCPToolResultBlock' + - $ref: '#/components/schemas/RequestContainerUploadBlock' + type: array + title: Content + role: + enum: + - user + - assistant + title: Role + type: string + required: + - content + - role + title: InputMessage + type: object + InputSchema: + additionalProperties: true + properties: + properties: + anyOf: + - type: object + - type: 'null' + title: Properties + required: + anyOf: + - items: + type: string + type: array + - type: 'null' + title: Required + type: + const: object + enum: + - object + title: Type + type: string + required: + - type + title: InputSchema + type: object + InvalidRequestError: + properties: + message: + default: Invalid request + title: Message + type: string + type: + const: invalid_request_error + default: invalid_request_error + enum: + - invalid_request_error + title: Type + type: string + required: + - message + - type + title: InvalidRequestError + type: object + Metadata: + additionalProperties: false + properties: + user_id: + anyOf: + - maxLength: 256 + type: string + - type: 'null' + description: >- + An external identifier for the user who is associated with the + request. + + + This should be a uuid, hash value, or other opaque identifier. + Anthropic may use this id to help detect abuse. Do not include any + identifying information such as name, email address, or phone + number. + examples: + - 13803d75-b4b5-4c3e-b2a2-6f21399b021b + title: User Id + title: Metadata + type: object + NotFoundError: + properties: + message: + default: Not found + title: Message + type: string + type: + const: not_found_error + default: not_found_error + enum: + - not_found_error + title: Type + type: string + required: + - message + - type + title: NotFoundError + type: object + OverloadedError: + properties: + message: + default: Overloaded + title: Message + type: string + type: + const: overloaded_error + default: overloaded_error + enum: + - overloaded_error + title: Type + type: string + required: + - message + - type + title: OverloadedError + type: object + PermissionError: + properties: + message: + default: Permission denied + title: Message + type: string + type: + const: permission_error + default: permission_error + enum: + - permission_error + title: Type + type: string + required: + - message + - type + title: PermissionError + type: object + PlainTextSource: + additionalProperties: false + properties: + data: + title: Data + type: string + media_type: + const: text/plain + enum: + - text/plain + title: Media Type + type: string + type: + const: text + enum: + - text + title: Type + type: string + required: + - data + - media_type + - type + title: Plain text + type: object + RateLimitError: + properties: + message: + default: Rate limited + title: Message + type: string + type: + const: rate_limit_error + default: rate_limit_error + enum: + - rate_limit_error + title: Type + type: string + required: + - message + - type + title: RateLimitError + type: object + RequestCharLocationCitation: + additionalProperties: false + properties: + cited_text: + title: Cited Text + type: string + document_index: + minimum: 0 + title: Document Index + type: integer + document_title: + anyOf: + - maxLength: 255 + minLength: 1 + type: string + - type: 'null' + title: Document Title + end_char_index: + title: End Char Index + type: integer + start_char_index: + minimum: 0 + title: Start Char Index + type: integer + type: + const: char_location + enum: + - char_location + title: Type + type: string + required: + - cited_text + - document_index + - document_title + - end_char_index + - start_char_index + - type + title: Character location + type: object + RequestCitationsConfig: + additionalProperties: false + properties: + enabled: + title: Enabled + type: boolean + title: RequestCitationsConfig + type: object + RequestCodeExecutionOutputBlock: + additionalProperties: false + properties: + file_id: + title: File Id + type: string + type: + const: code_execution_output + enum: + - code_execution_output + title: Type + type: string + required: + - file_id + - type + title: RequestCodeExecutionOutputBlock + type: object + RequestCodeExecutionResultBlock: + additionalProperties: false + properties: + content: + items: + $ref: '#/components/schemas/RequestCodeExecutionOutputBlock' + title: Content + type: array + return_code: + title: Return Code + type: integer + stderr: + title: Stderr + type: string + stdout: + title: Stdout + type: string + type: + const: code_execution_result + enum: + - code_execution_result + title: Type + type: string + required: + - content + - return_code + - stderr + - stdout + - type + title: Code execution result + type: object + RequestCodeExecutionToolResultBlock: + additionalProperties: false + properties: + cache_control: + anyOf: + - discriminator: + mapping: + ephemeral: '#/components/schemas/CacheControlEphemeral' + propertyName: type + oneOf: + - $ref: '#/components/schemas/CacheControlEphemeral' + - type: 'null' + description: Create a cache control breakpoint at this content block. + title: Cache Control + content: + anyOf: + - $ref: '#/components/schemas/RequestCodeExecutionToolResultError' + - $ref: '#/components/schemas/RequestCodeExecutionResultBlock' + title: Content + tool_use_id: + pattern: ^srvtoolu_[a-zA-Z0-9_]+$ + title: Tool Use Id + type: string + type: + const: code_execution_tool_result + enum: + - code_execution_tool_result + title: Type + type: string + required: + - content + - tool_use_id + - type + title: Code execution tool result + type: object + RequestCodeExecutionToolResultError: + additionalProperties: false + properties: + error_code: + $ref: '#/components/schemas/CodeExecutionToolResultErrorCode' + type: + const: code_execution_tool_result_error + enum: + - code_execution_tool_result_error + title: Type + type: string + required: + - error_code + - type + title: Code execution tool error + type: object + RequestContainerUploadBlock: + additionalProperties: false + description: >- + A content block that represents a file to be uploaded to the container + + Files uploaded via this block will be available in the container's input + directory. + properties: + cache_control: + anyOf: + - discriminator: + mapping: + ephemeral: '#/components/schemas/CacheControlEphemeral' + propertyName: type + oneOf: + - $ref: '#/components/schemas/CacheControlEphemeral' + - type: 'null' + description: Create a cache control breakpoint at this content block. + title: Cache Control + file_id: + title: File Id + type: string + type: + const: container_upload + enum: + - container_upload + title: Type + type: string + required: + - file_id + - type + title: Container upload + type: object + RequestContentBlockLocationCitation: + additionalProperties: false + properties: + cited_text: + title: Cited Text + type: string + document_index: + minimum: 0 + title: Document Index + type: integer + document_title: + anyOf: + - maxLength: 255 + minLength: 1 + type: string + - type: 'null' + title: Document Title + end_block_index: + title: End Block Index + type: integer + start_block_index: + minimum: 0 + title: Start Block Index + type: integer + type: + const: content_block_location + enum: + - content_block_location + title: Type + type: string + required: + - cited_text + - document_index + - document_title + - end_block_index + - start_block_index + - type + title: Content block location + type: object + RequestDocumentBlock: + additionalProperties: false + properties: + cache_control: + anyOf: + - discriminator: + mapping: + ephemeral: '#/components/schemas/CacheControlEphemeral' + propertyName: type + oneOf: + - $ref: '#/components/schemas/CacheControlEphemeral' + - type: 'null' + description: Create a cache control breakpoint at this content block. + title: Cache Control + citations: + $ref: '#/components/schemas/RequestCitationsConfig' + context: + anyOf: + - minLength: 1 + type: string + - type: 'null' + title: Context + source: + discriminator: + mapping: + base64: '#/components/schemas/Base64PDFSource' + content: '#/components/schemas/ContentBlockSource' + file: '#/components/schemas/FileDocumentSource' + text: '#/components/schemas/PlainTextSource' + url: '#/components/schemas/URLPDFSource' + propertyName: type + oneOf: + - $ref: '#/components/schemas/Base64PDFSource' + - $ref: '#/components/schemas/PlainTextSource' + - $ref: '#/components/schemas/ContentBlockSource' + - $ref: '#/components/schemas/URLPDFSource' + - $ref: '#/components/schemas/FileDocumentSource' + title: + anyOf: + - maxLength: 500 + minLength: 1 + type: string + - type: 'null' + title: Title + type: + const: document + enum: + - document + title: Type + type: string + required: + - source + - type + title: Document + type: object + RequestImageBlock: + additionalProperties: false + properties: + cache_control: + anyOf: + - discriminator: + mapping: + ephemeral: '#/components/schemas/CacheControlEphemeral' + propertyName: type + oneOf: + - $ref: '#/components/schemas/CacheControlEphemeral' + - type: 'null' + description: Create a cache control breakpoint at this content block. + title: Cache Control + source: + discriminator: + mapping: + base64: '#/components/schemas/Base64ImageSource' + file: '#/components/schemas/FileImageSource' + url: '#/components/schemas/URLImageSource' + propertyName: type + oneOf: + - $ref: '#/components/schemas/Base64ImageSource' + - $ref: '#/components/schemas/URLImageSource' + - $ref: '#/components/schemas/FileImageSource' + title: Source + type: + const: image + enum: + - image + title: Type + type: string + required: + - source + - type + title: Image + type: object + RequestMCPServerToolConfiguration: + additionalProperties: false + properties: + allowed_tools: + anyOf: + - items: + type: string + type: array + - type: 'null' + title: Allowed Tools + enabled: + anyOf: + - type: boolean + - type: 'null' + title: Enabled + title: RequestMCPServerToolConfiguration + type: object + RequestMCPServerURLDefinition: + additionalProperties: false + properties: + authorization_token: + anyOf: + - type: string + - type: 'null' + title: Authorization Token + name: + title: Name + type: string + tool_configuration: + anyOf: + - $ref: '#/components/schemas/RequestMCPServerToolConfiguration' + - type: 'null' + type: + const: url + enum: + - url + title: Type + type: string + url: + title: Url + type: string + required: + - name + - type + - url + title: RequestMCPServerURLDefinition + type: object + RequestMCPToolResultBlock: + additionalProperties: false + properties: + cache_control: + anyOf: + - discriminator: + mapping: + ephemeral: '#/components/schemas/CacheControlEphemeral' + propertyName: type + oneOf: + - $ref: '#/components/schemas/CacheControlEphemeral' + - type: 'null' + description: Create a cache control breakpoint at this content block. + title: Cache Control + content: + anyOf: + - type: string + - items: + $ref: '#/components/schemas/RequestTextBlock' + type: array + title: Content + is_error: + title: Is Error + type: boolean + tool_use_id: + pattern: ^[a-zA-Z0-9_-]+$ + title: Tool Use Id + type: string + type: + const: mcp_tool_result + enum: + - mcp_tool_result + title: Type + type: string + required: + - tool_use_id + - type + title: MCP tool result + type: object + RequestMCPToolUseBlock: + additionalProperties: false + properties: + cache_control: + anyOf: + - discriminator: + mapping: + ephemeral: '#/components/schemas/CacheControlEphemeral' + propertyName: type + oneOf: + - $ref: '#/components/schemas/CacheControlEphemeral' + - type: 'null' + description: Create a cache control breakpoint at this content block. + title: Cache Control + id: + pattern: ^[a-zA-Z0-9_-]+$ + title: Id + type: string + input: + title: Input + type: object + name: + title: Name + type: string + server_name: + description: The name of the MCP server + title: Server Name + type: string + type: + const: mcp_tool_use + enum: + - mcp_tool_use + title: Type + type: string + required: + - id + - input + - name + - server_name + - type + title: MCP tool use + type: object + RequestPageLocationCitation: + additionalProperties: false + properties: + cited_text: + title: Cited Text + type: string + document_index: + minimum: 0 + title: Document Index + type: integer + document_title: + anyOf: + - maxLength: 255 + minLength: 1 + type: string + - type: 'null' + title: Document Title + end_page_number: + title: End Page Number + type: integer + start_page_number: + minimum: 1 + title: Start Page Number + type: integer + type: + const: page_location + enum: + - page_location + title: Type + type: string + required: + - cited_text + - document_index + - document_title + - end_page_number + - start_page_number + - type + title: Page location + type: object + RequestRedactedThinkingBlock: + additionalProperties: false + properties: + data: + title: Data + type: string + type: + const: redacted_thinking + enum: + - redacted_thinking + title: Type + type: string + required: + - data + - type + title: Redacted thinking + type: object + RequestSearchResultBlock: + additionalProperties: false + properties: + cache_control: + anyOf: + - discriminator: + mapping: + ephemeral: '#/components/schemas/CacheControlEphemeral' + propertyName: type + oneOf: + - $ref: '#/components/schemas/CacheControlEphemeral' + - type: 'null' + description: Create a cache control breakpoint at this content block. + title: Cache Control + citations: + $ref: '#/components/schemas/RequestCitationsConfig' + content: + items: + $ref: '#/components/schemas/RequestTextBlock' + title: Content + type: array + source: + title: Source + type: string + title: + title: Title + type: string + type: + const: search_result + enum: + - search_result + title: Type + type: string + required: + - content + - source + - title + - type + title: Search result + type: object + RequestSearchResultLocationCitation: + additionalProperties: false + properties: + cited_text: + title: Cited Text + type: string + end_block_index: + title: End Block Index + type: integer + search_result_index: + minimum: 0 + title: Search Result Index + type: integer + source: + title: Source + type: string + start_block_index: + minimum: 0 + title: Start Block Index + type: integer + title: + anyOf: + - type: string + - type: 'null' + title: Title + type: + const: search_result_location + enum: + - search_result_location + title: Type + type: string + required: + - cited_text + - end_block_index + - search_result_index + - source + - start_block_index + - title + - type + title: RequestSearchResultLocationCitation + type: object + RequestServerToolUseBlock: + additionalProperties: false + properties: + cache_control: + anyOf: + - discriminator: + mapping: + ephemeral: '#/components/schemas/CacheControlEphemeral' + propertyName: type + oneOf: + - $ref: '#/components/schemas/CacheControlEphemeral' + - type: 'null' + description: Create a cache control breakpoint at this content block. + title: Cache Control + id: + pattern: ^srvtoolu_[a-zA-Z0-9_]+$ + title: Id + type: string + input: + title: Input + type: object + name: + enum: + - web_search + - code_execution + title: Name + type: string + type: + const: server_tool_use + enum: + - server_tool_use + title: Type + type: string + required: + - id + - input + - name + - type + title: Server tool use + type: object + RequestTextBlock: + additionalProperties: false + properties: + cache_control: + anyOf: + - discriminator: + mapping: + ephemeral: '#/components/schemas/CacheControlEphemeral' + propertyName: type + oneOf: + - $ref: '#/components/schemas/CacheControlEphemeral' + - type: 'null' + description: Create a cache control breakpoint at this content block. + title: Cache Control + citations: + anyOf: + - items: + discriminator: + mapping: + char_location: '#/components/schemas/RequestCharLocationCitation' + content_block_location: '#/components/schemas/RequestContentBlockLocationCitation' + page_location: '#/components/schemas/RequestPageLocationCitation' + search_result_location: '#/components/schemas/RequestSearchResultLocationCitation' + web_search_result_location: >- + #/components/schemas/RequestWebSearchResultLocationCitation + propertyName: type + oneOf: + - $ref: '#/components/schemas/RequestCharLocationCitation' + - $ref: '#/components/schemas/RequestPageLocationCitation' + - $ref: '#/components/schemas/RequestContentBlockLocationCitation' + - $ref: >- + #/components/schemas/RequestWebSearchResultLocationCitation + - $ref: '#/components/schemas/RequestSearchResultLocationCitation' + type: array + - type: 'null' + title: Citations + text: + minLength: 1 + title: Text + type: string + type: + const: text + enum: + - text + title: Type + type: string + required: + - text + - type + title: Text + type: object + RequestThinkingBlock: + additionalProperties: false + properties: + signature: + title: Signature + type: string + thinking: + title: Thinking + type: string + type: + const: thinking + enum: + - thinking + title: Type + type: string + required: + - signature + - thinking + - type + title: Thinking + type: object + RequestToolResultBlock: + additionalProperties: false + properties: + cache_control: + anyOf: + - discriminator: + mapping: + ephemeral: '#/components/schemas/CacheControlEphemeral' + propertyName: type + oneOf: + - $ref: '#/components/schemas/CacheControlEphemeral' + - type: 'null' + description: Create a cache control breakpoint at this content block. + title: Cache Control + content: + anyOf: + - type: string + - items: + discriminator: + mapping: + image: '#/components/schemas/RequestImageBlock' + search_result: '#/components/schemas/RequestSearchResultBlock' + text: '#/components/schemas/RequestTextBlock' + propertyName: type + oneOf: + - $ref: '#/components/schemas/RequestTextBlock' + - $ref: '#/components/schemas/RequestImageBlock' + - $ref: '#/components/schemas/RequestSearchResultBlock' + type: array + title: Content + is_error: + title: Is Error + type: boolean + tool_use_id: + pattern: ^[a-zA-Z0-9_-]+$ + title: Tool Use Id + type: string + type: + const: tool_result + enum: + - tool_result + title: Type + type: string + required: + - tool_use_id + - type + title: Tool result + type: object + RequestToolUseBlock: + additionalProperties: false + properties: + cache_control: + anyOf: + - discriminator: + mapping: + ephemeral: '#/components/schemas/CacheControlEphemeral' + propertyName: type + oneOf: + - $ref: '#/components/schemas/CacheControlEphemeral' + - type: 'null' + description: Create a cache control breakpoint at this content block. + title: Cache Control + id: + pattern: ^[a-zA-Z0-9_-]+$ + title: Id + type: string + input: + title: Input + type: object + name: + maxLength: 200 + minLength: 1 + title: Name + type: string + type: + const: tool_use + enum: + - tool_use + title: Type + type: string + required: + - id + - input + - name + - type + title: Tool use + type: object + RequestWebSearchResultBlock: + additionalProperties: false + properties: + encrypted_content: + title: Encrypted Content + type: string + page_age: + anyOf: + - type: string + - type: 'null' + title: Page Age + title: + title: Title + type: string + type: + const: web_search_result + enum: + - web_search_result + title: Type + type: string + url: + title: Url + type: string + required: + - encrypted_content + - title + - type + - url + title: RequestWebSearchResultBlock + type: object + RequestWebSearchResultLocationCitation: + additionalProperties: false + properties: + cited_text: + title: Cited Text + type: string + encrypted_index: + title: Encrypted Index + type: string + title: + anyOf: + - maxLength: 512 + minLength: 1 + type: string + - type: 'null' + title: Title + type: + const: web_search_result_location + enum: + - web_search_result_location + title: Type + type: string + url: + maxLength: 2048 + minLength: 1 + title: Url + type: string + required: + - cited_text + - encrypted_index + - title + - type + - url + title: RequestWebSearchResultLocationCitation + type: object + RequestWebSearchToolResultBlock: + additionalProperties: false + properties: + cache_control: + anyOf: + - discriminator: + mapping: + ephemeral: '#/components/schemas/CacheControlEphemeral' + propertyName: type + oneOf: + - $ref: '#/components/schemas/CacheControlEphemeral' + - type: 'null' + description: Create a cache control breakpoint at this content block. + title: Cache Control + content: + anyOf: + - items: + $ref: '#/components/schemas/RequestWebSearchResultBlock' + type: array + - $ref: '#/components/schemas/RequestWebSearchToolResultError' + title: Content + tool_use_id: + pattern: ^srvtoolu_[a-zA-Z0-9_]+$ + title: Tool Use Id + type: string + type: + const: web_search_tool_result + enum: + - web_search_tool_result + title: Type + type: string + required: + - content + - tool_use_id + - type + title: Web search tool result + type: object + RequestWebSearchToolResultError: + additionalProperties: false + properties: + error_code: + $ref: '#/components/schemas/WebSearchToolResultErrorCode' + type: + const: web_search_tool_result_error + enum: + - web_search_tool_result_error + title: Type + type: string + required: + - error_code + - type + title: RequestWebSearchToolResultError + type: object + ResponseCharLocationCitation: + properties: + cited_text: + title: Cited Text + type: string + document_index: + minimum: 0 + title: Document Index + type: integer + document_title: + anyOf: + - type: string + - type: 'null' + title: Document Title + end_char_index: + title: End Char Index + type: integer + file_id: + anyOf: + - type: string + - type: 'null' + default: null + title: File Id + start_char_index: + minimum: 0 + title: Start Char Index + type: integer + type: + const: char_location + default: char_location + enum: + - char_location + title: Type + type: string + required: + - cited_text + - document_index + - document_title + - end_char_index + - file_id + - start_char_index + - type + title: Character location + type: object + ResponseCodeExecutionOutputBlock: + properties: + file_id: + title: File Id + type: string + type: + const: code_execution_output + default: code_execution_output + enum: + - code_execution_output + title: Type + type: string + required: + - file_id + - type + title: ResponseCodeExecutionOutputBlock + type: object + ResponseCodeExecutionResultBlock: + properties: + content: + items: + $ref: '#/components/schemas/ResponseCodeExecutionOutputBlock' + title: Content + type: array + return_code: + title: Return Code + type: integer + stderr: + title: Stderr + type: string + stdout: + title: Stdout + type: string + type: + const: code_execution_result + default: code_execution_result + enum: + - code_execution_result + title: Type + type: string + required: + - content + - return_code + - stderr + - stdout + - type + title: Code execution result + type: object + ResponseCodeExecutionToolResultBlock: + properties: + content: + anyOf: + - $ref: '#/components/schemas/ResponseCodeExecutionToolResultError' + - $ref: '#/components/schemas/ResponseCodeExecutionResultBlock' + title: Content + tool_use_id: + pattern: ^srvtoolu_[a-zA-Z0-9_]+$ + title: Tool Use Id + type: string + type: + const: code_execution_tool_result + default: code_execution_tool_result + enum: + - code_execution_tool_result + title: Type + type: string + required: + - content + - tool_use_id + - type + title: Code execution tool result + type: object + ResponseCodeExecutionToolResultError: + properties: + error_code: + $ref: '#/components/schemas/CodeExecutionToolResultErrorCode' + type: + const: code_execution_tool_result_error + default: code_execution_tool_result_error + enum: + - code_execution_tool_result_error + title: Type + type: string + required: + - error_code + - type + title: Code execution tool error + type: object + ResponseContainerUploadBlock: + description: Response model for a file uploaded to the container. + properties: + file_id: + title: File Id + type: string + type: + const: container_upload + default: container_upload + enum: + - container_upload + title: Type + type: string + required: + - file_id + - type + title: Container upload + type: object + ResponseContentBlockLocationCitation: + properties: + cited_text: + title: Cited Text + type: string + document_index: + minimum: 0 + title: Document Index + type: integer + document_title: + anyOf: + - type: string + - type: 'null' + title: Document Title + end_block_index: + title: End Block Index + type: integer + file_id: + anyOf: + - type: string + - type: 'null' + default: null + title: File Id + start_block_index: + minimum: 0 + title: Start Block Index + type: integer + type: + const: content_block_location + default: content_block_location + enum: + - content_block_location + title: Type + type: string + required: + - cited_text + - document_index + - document_title + - end_block_index + - file_id + - start_block_index + - type + title: Content block location + type: object + ResponseMCPToolResultBlock: + properties: + content: + anyOf: + - type: string + - items: + $ref: '#/components/schemas/ResponseTextBlock' + type: array + title: Content + is_error: + default: false + title: Is Error + type: boolean + tool_use_id: + pattern: ^[a-zA-Z0-9_-]+$ + title: Tool Use Id + type: string + type: + const: mcp_tool_result + default: mcp_tool_result + enum: + - mcp_tool_result + title: Type + type: string + required: + - content + - is_error + - tool_use_id + - type + title: MCP tool result + type: object + ResponseMCPToolUseBlock: + properties: + id: + pattern: ^[a-zA-Z0-9_-]+$ + title: Id + type: string + input: + title: Input + type: object + name: + description: The name of the MCP tool + title: Name + type: string + server_name: + description: The name of the MCP server + title: Server Name + type: string + type: + const: mcp_tool_use + default: mcp_tool_use + enum: + - mcp_tool_use + title: Type + type: string + required: + - id + - input + - name + - server_name + - type + title: MCP tool use + type: object + ResponsePageLocationCitation: + properties: + cited_text: + title: Cited Text + type: string + document_index: + minimum: 0 + title: Document Index + type: integer + document_title: + anyOf: + - type: string + - type: 'null' + title: Document Title + end_page_number: + title: End Page Number + type: integer + file_id: + anyOf: + - type: string + - type: 'null' + default: null + title: File Id + start_page_number: + minimum: 1 + title: Start Page Number + type: integer + type: + const: page_location + default: page_location + enum: + - page_location + title: Type + type: string + required: + - cited_text + - document_index + - document_title + - end_page_number + - file_id + - start_page_number + - type + title: Page location + type: object + ResponseRedactedThinkingBlock: + properties: + data: + title: Data + type: string + type: + const: redacted_thinking + default: redacted_thinking + enum: + - redacted_thinking + title: Type + type: string + required: + - data + - type + title: Redacted thinking + type: object + ResponseSearchResultLocationCitation: + properties: + cited_text: + title: Cited Text + type: string + end_block_index: + title: End Block Index + type: integer + search_result_index: + minimum: 0 + title: Search Result Index + type: integer + source: + title: Source + type: string + start_block_index: + minimum: 0 + title: Start Block Index + type: integer + title: + anyOf: + - type: string + - type: 'null' + title: Title + type: + const: search_result_location + default: search_result_location + enum: + - search_result_location + title: Type + type: string + required: + - cited_text + - end_block_index + - search_result_index + - source + - start_block_index + - title + - type + title: ResponseSearchResultLocationCitation + type: object + ResponseServerToolUseBlock: + properties: + id: + pattern: ^srvtoolu_[a-zA-Z0-9_]+$ + title: Id + type: string + input: + title: Input + type: object + name: + enum: + - web_search + - code_execution + title: Name + type: string + type: + const: server_tool_use + default: server_tool_use + enum: + - server_tool_use + title: Type + type: string + required: + - id + - input + - name + - type + title: Server tool use + type: object + ResponseTextBlock: + properties: + citations: + anyOf: + - items: + discriminator: + mapping: + char_location: '#/components/schemas/ResponseCharLocationCitation' + content_block_location: '#/components/schemas/ResponseContentBlockLocationCitation' + page_location: '#/components/schemas/ResponsePageLocationCitation' + search_result_location: '#/components/schemas/ResponseSearchResultLocationCitation' + web_search_result_location: >- + #/components/schemas/ResponseWebSearchResultLocationCitation + propertyName: type + oneOf: + - $ref: '#/components/schemas/ResponseCharLocationCitation' + - $ref: '#/components/schemas/ResponsePageLocationCitation' + - $ref: '#/components/schemas/ResponseContentBlockLocationCitation' + - $ref: >- + #/components/schemas/ResponseWebSearchResultLocationCitation + - $ref: '#/components/schemas/ResponseSearchResultLocationCitation' + type: array + - type: 'null' + default: null + description: >- + Citations supporting the text block. + + + The type of citation returned will depend on the type of document + being cited. Citing a PDF results in `page_location`, plain text + results in `char_location`, and content document results in + `content_block_location`. + title: Citations + text: + maxLength: 5000000 + minLength: 0 + title: Text + type: string + type: + const: text + default: text + enum: + - text + title: Type + type: string + required: + - citations + - text + - type + title: Text + type: object + ResponseThinkingBlock: + properties: + signature: + title: Signature + type: string + thinking: + title: Thinking + type: string + type: + const: thinking + default: thinking + enum: + - thinking + title: Type + type: string + required: + - signature + - thinking + - type + title: Thinking + type: object + ResponseToolUseBlock: + properties: + id: + pattern: ^[a-zA-Z0-9_-]+$ + title: Id + type: string + input: + title: Input + type: object + name: + minLength: 1 + title: Name + type: string + type: + const: tool_use + default: tool_use + enum: + - tool_use + title: Type + type: string + required: + - id + - input + - name + - type + title: Tool use + type: object + ResponseWebSearchResultBlock: + properties: + encrypted_content: + title: Encrypted Content + type: string + page_age: + anyOf: + - type: string + - type: 'null' + default: null + title: Page Age + title: + title: Title + type: string + type: + const: web_search_result + default: web_search_result + enum: + - web_search_result + title: Type + type: string + url: + title: Url + type: string + required: + - encrypted_content + - page_age + - title + - type + - url + title: ResponseWebSearchResultBlock + type: object + ResponseWebSearchResultLocationCitation: + properties: + cited_text: + title: Cited Text + type: string + encrypted_index: + title: Encrypted Index + type: string + title: + anyOf: + - maxLength: 512 + type: string + - type: 'null' + title: Title + type: + const: web_search_result_location + default: web_search_result_location + enum: + - web_search_result_location + title: Type + type: string + url: + title: Url + type: string + required: + - cited_text + - encrypted_index + - title + - type + - url + title: ResponseWebSearchResultLocationCitation + type: object + ResponseWebSearchToolResultBlock: + properties: + content: + anyOf: + - $ref: '#/components/schemas/ResponseWebSearchToolResultError' + - items: + $ref: '#/components/schemas/ResponseWebSearchResultBlock' + type: array + title: Content + tool_use_id: + pattern: ^srvtoolu_[a-zA-Z0-9_]+$ + title: Tool Use Id + type: string + type: + const: web_search_tool_result + default: web_search_tool_result + enum: + - web_search_tool_result + title: Type + type: string + required: + - content + - tool_use_id + - type + title: Web search tool result + type: object + ResponseWebSearchToolResultError: + properties: + error_code: + $ref: '#/components/schemas/WebSearchToolResultErrorCode' + type: + const: web_search_tool_result_error + default: web_search_tool_result_error + enum: + - web_search_tool_result_error + title: Type + type: string + required: + - error_code + - type + title: ResponseWebSearchToolResultError + type: object + ServerToolUsage: + properties: + web_search_requests: + default: 0 + description: The number of web search tool requests. + examples: + - 0 + minimum: 0 + title: Web Search Requests + type: integer + required: + - web_search_requests + title: ServerToolUsage + type: object + TextEditor_20241022: + additionalProperties: false + properties: + cache_control: + anyOf: + - discriminator: + mapping: + ephemeral: '#/components/schemas/CacheControlEphemeral' + propertyName: type + oneOf: + - $ref: '#/components/schemas/CacheControlEphemeral' + - type: 'null' + description: Create a cache control breakpoint at this content block. + title: Cache Control + name: + const: str_replace_editor + description: >- + Name of the tool. + + + This is how the tool will be called by the model and in `tool_use` + blocks. + enum: + - str_replace_editor + title: Name + type: string + type: + const: text_editor_20241022 + enum: + - text_editor_20241022 + title: Type + type: string + required: + - name + - type + title: Text editor tool (2024-10-22) + type: object + TextEditor_20250124: + additionalProperties: false + properties: + cache_control: + anyOf: + - discriminator: + mapping: + ephemeral: '#/components/schemas/CacheControlEphemeral' + propertyName: type + oneOf: + - $ref: '#/components/schemas/CacheControlEphemeral' + - type: 'null' + description: Create a cache control breakpoint at this content block. + title: Cache Control + name: + const: str_replace_editor + description: >- + Name of the tool. + + + This is how the tool will be called by the model and in `tool_use` + blocks. + enum: + - str_replace_editor + title: Name + type: string + type: + const: text_editor_20250124 + enum: + - text_editor_20250124 + title: Type + type: string + required: + - name + - type + title: Text editor tool (2025-01-24) + type: object + TextEditor_20250429: + additionalProperties: false + properties: + cache_control: + anyOf: + - discriminator: + mapping: + ephemeral: '#/components/schemas/CacheControlEphemeral' + propertyName: type + oneOf: + - $ref: '#/components/schemas/CacheControlEphemeral' + - type: 'null' + description: Create a cache control breakpoint at this content block. + title: Cache Control + name: + const: str_replace_based_edit_tool + description: >- + Name of the tool. + + + This is how the tool will be called by the model and in `tool_use` + blocks. + enum: + - str_replace_based_edit_tool + title: Name + type: string + type: + const: text_editor_20250429 + enum: + - text_editor_20250429 + title: Type + type: string + required: + - name + - type + title: Text editor tool (2025-04-29) + type: object + TextEditor_20250728: + additionalProperties: false + properties: + cache_control: + anyOf: + - discriminator: + mapping: + ephemeral: '#/components/schemas/CacheControlEphemeral' + propertyName: type + oneOf: + - $ref: '#/components/schemas/CacheControlEphemeral' + - type: 'null' + description: Create a cache control breakpoint at this content block. + title: Cache Control + max_characters: + anyOf: + - minimum: 1 + type: integer + - type: 'null' + description: >- + Maximum number of characters to display when viewing a file. If not + specified, defaults to displaying the full file. + title: Max Characters + name: + const: str_replace_based_edit_tool + description: >- + Name of the tool. + + + This is how the tool will be called by the model and in `tool_use` + blocks. + enum: + - str_replace_based_edit_tool + title: Name + type: string + type: + const: text_editor_20250728 + enum: + - text_editor_20250728 + title: Type + type: string + required: + - name + - type + title: TextEditor_20250728 + type: object + ThinkingConfigDisabled: + additionalProperties: false + properties: + type: + const: disabled + enum: + - disabled + title: Type + type: string + required: + - type + title: Disabled + type: object + ThinkingConfigEnabled: + additionalProperties: false + properties: + budget_tokens: + description: >- + Determines how many tokens Claude can use for its internal reasoning + process. Larger budgets can enable more thorough analysis for + complex problems, improving response quality. + + + Must be ≥1024 and less than `max_tokens`. + + + See [extended + thinking](https://docs.anthropic.com/en/docs/build-with-claude/extended-thinking) + for details. + minimum: 1024 + title: Budget Tokens + type: integer + type: + const: enabled + enum: + - enabled + title: Type + type: string + required: + - budget_tokens + - type + title: Enabled + type: object + Tool: + additionalProperties: false + properties: + type: + anyOf: + - type: 'null' + - const: custom + enum: + - custom + type: string + title: Type + description: + description: >- + Description of what this tool does. + + + Tool descriptions should be as detailed as possible. The more + information that the model has about what the tool is and how to use + it, the better it will perform. You can use natural language + descriptions to reinforce important aspects of the tool input JSON + schema. + examples: + - Get the current weather in a given location + title: Description + type: string + name: + description: >- + Name of the tool. + + + This is how the tool will be called by the model and in `tool_use` + blocks. + maxLength: 128 + minLength: 1 + pattern: ^[a-zA-Z0-9_-]{1,128}$ + title: Name + type: string + input_schema: + $ref: '#/components/schemas/InputSchema' + description: >- + [JSON schema](https://json-schema.org/draft/2020-12) for this tool's + input. + + + This defines the shape of the `input` that your tool accepts and + that the model will produce. + examples: + - properties: + location: + description: The city and state, e.g. San Francisco, CA + type: string + unit: + description: Unit for the output - one of (celsius, fahrenheit) + type: string + required: + - location + type: object + cache_control: + anyOf: + - discriminator: + mapping: + ephemeral: '#/components/schemas/CacheControlEphemeral' + propertyName: type + oneOf: + - $ref: '#/components/schemas/CacheControlEphemeral' + - type: 'null' + description: Create a cache control breakpoint at this content block. + title: Cache Control + required: + - name + - input_schema + title: Custom tool + type: object + ToolChoiceAny: + additionalProperties: false + description: The model will use any available tools. + properties: + disable_parallel_tool_use: + description: >- + Whether to disable parallel tool use. + + + Defaults to `false`. If set to `true`, the model will output exactly + one tool use. + title: Disable Parallel Tool Use + type: boolean + type: + const: any + enum: + - any + title: Type + type: string + required: + - type + title: Any + type: object + ToolChoiceAuto: + additionalProperties: false + description: The model will automatically decide whether to use tools. + properties: + disable_parallel_tool_use: + description: >- + Whether to disable parallel tool use. + + + Defaults to `false`. If set to `true`, the model will output at most + one tool use. + title: Disable Parallel Tool Use + type: boolean + type: + const: auto + enum: + - auto + title: Type + type: string + required: + - type + title: Auto + type: object + ToolChoiceNone: + additionalProperties: false + description: The model will not be allowed to use tools. + properties: + type: + const: none + enum: + - none + title: Type + type: string + required: + - type + title: None + type: object + ToolChoiceTool: + additionalProperties: false + description: The model will use the specified tool with `tool_choice.name`. + properties: + disable_parallel_tool_use: + description: >- + Whether to disable parallel tool use. + + + Defaults to `false`. If set to `true`, the model will output exactly + one tool use. + title: Disable Parallel Tool Use + type: boolean + name: + description: The name of the tool to use. + title: Name + type: string + type: + const: tool + enum: + - tool + title: Type + type: string + required: + - name + - type + title: Tool + type: object + URLImageSource: + additionalProperties: false + properties: + type: + const: url + enum: + - url + title: Type + type: string + url: + title: Url + type: string + required: + - type + - url + title: URLImageSource + type: object + URLPDFSource: + additionalProperties: false + properties: + type: + const: url + enum: + - url + title: Type + type: string + url: + title: Url + type: string + required: + - type + - url + title: PDF (URL) + type: object + Usage: + properties: + cache_creation: + anyOf: + - $ref: '#/components/schemas/CacheCreation' + - type: 'null' + default: null + description: Breakdown of cached tokens by TTL + cache_creation_input_tokens: + anyOf: + - minimum: 0 + type: integer + - type: 'null' + default: null + description: The number of input tokens used to create the cache entry. + examples: + - 2051 + title: Cache Creation Input Tokens + cache_read_input_tokens: + anyOf: + - minimum: 0 + type: integer + - type: 'null' + default: null + description: The number of input tokens read from the cache. + examples: + - 2051 + title: Cache Read Input Tokens + input_tokens: + description: The number of input tokens which were used. + examples: + - 2095 + minimum: 0 + title: Input Tokens + type: integer + output_tokens: + description: The number of output tokens which were used. + examples: + - 503 + minimum: 0 + title: Output Tokens + type: integer + server_tool_use: + anyOf: + - $ref: '#/components/schemas/ServerToolUsage' + - type: 'null' + default: null + description: The number of server tool requests. + service_tier: + anyOf: + - enum: + - standard + - priority + - batch + type: string + - type: 'null' + default: null + description: If the request used the priority, standard, or batch tier. + title: Service Tier + required: + - cache_creation + - cache_creation_input_tokens + - cache_read_input_tokens + - input_tokens + - output_tokens + - server_tool_use + - service_tier + title: Usage + type: object + UserLocation: + additionalProperties: false + properties: + city: + anyOf: + - maxLength: 255 + minLength: 1 + type: string + - type: 'null' + description: The city of the user. + examples: + - New York + - Tokyo + - Los Angeles + title: City + country: + anyOf: + - maxLength: 2 + minLength: 2 + type: string + - type: 'null' + description: >- + The two letter [ISO country + code](https://en.wikipedia.org/wiki/ISO_3166-1_alpha-2) of the user. + examples: + - US + - JP + - GB + title: Country + region: + anyOf: + - maxLength: 255 + minLength: 1 + type: string + - type: 'null' + description: The region of the user. + examples: + - California + - Ontario + - Wales + title: Region + timezone: + anyOf: + - maxLength: 255 + minLength: 1 + type: string + - type: 'null' + description: The [IANA timezone](https://nodatime.org/TimeZones) of the user. + examples: + - America/New_York + - Asia/Tokyo + - Europe/London + title: Timezone + type: + const: approximate + enum: + - approximate + title: Type + type: string + required: + - type + title: UserLocation + type: object + WebSearchToolResultErrorCode: + enum: + - invalid_tool_input + - unavailable + - max_uses_exceeded + - too_many_requests + - query_too_long + title: WebSearchToolResultErrorCode + type: string + WebSearchTool_20250305: + additionalProperties: false + properties: + allowed_domains: + anyOf: + - items: + type: string + type: array + - type: 'null' + description: >- + If provided, only these domains will be included in results. Cannot + be used alongside `blocked_domains`. + title: Allowed Domains + blocked_domains: + anyOf: + - items: + type: string + type: array + - type: 'null' + description: >- + If provided, these domains will never appear in results. Cannot be + used alongside `allowed_domains`. + title: Blocked Domains + cache_control: + anyOf: + - discriminator: + mapping: + ephemeral: '#/components/schemas/CacheControlEphemeral' + propertyName: type + oneOf: + - $ref: '#/components/schemas/CacheControlEphemeral' + - type: 'null' + description: Create a cache control breakpoint at this content block. + title: Cache Control + max_uses: + anyOf: + - exclusiveMinimum: 0 + type: integer + - type: 'null' + description: Maximum number of times the tool can be used in the API request. + title: Max Uses + name: + const: web_search + description: >- + Name of the tool. + + + This is how the tool will be called by the model and in `tool_use` + blocks. + enum: + - web_search + title: Name + type: string + type: + const: web_search_20250305 + enum: + - web_search_20250305 + title: Type + type: string + user_location: + anyOf: + - $ref: '#/components/schemas/UserLocation' + - type: 'null' + description: >- + Parameters for the user's location. Used to provide more relevant + search results. + required: + - name + - type + title: Web search tool (2025-03-05) + type: object diff --git a/plugins/wasm-go/extensions/ai-proxy/main.go b/plugins/wasm-go/extensions/ai-proxy/main.go index 7429788ac..dd9988911 100644 --- a/plugins/wasm-go/extensions/ai-proxy/main.go +++ b/plugins/wasm-go/extensions/ai-proxy/main.go @@ -340,17 +340,20 @@ func onHttpResponseHeaders(ctx wrapper.HttpContext, pluginConfig config.PluginCo } util.ReplaceResponseHeaders(headers) - checkStream(ctx) _, needHandleBody := activeProvider.(provider.TransformResponseBodyHandler) var needHandleStreamingBody bool _, needHandleStreamingBody = activeProvider.(provider.StreamingResponseBodyHandler) if !needHandleStreamingBody { _, needHandleStreamingBody = activeProvider.(provider.StreamingEventHandler) } - if !needHandleBody && !needHandleStreamingBody { + + // Check if we need to read body for Claude response conversion + needClaudeConversion, _ := ctx.GetContext("needClaudeResponseConversion").(bool) + + if !needHandleBody && !needHandleStreamingBody && !needClaudeConversion { ctx.DontReadResponseBody() - } else if !needHandleStreamingBody { - ctx.BufferResponseBody() + } else { + checkStream(ctx) } return types.ActionContinue @@ -371,19 +374,12 @@ func onStreamingResponseBody(ctx wrapper.HttpContext, pluginConfig config.Plugin apiName, _ := ctx.GetContext(provider.CtxKeyApiName).(provider.ApiName) modifiedChunk, err := handler.OnStreamingResponseBody(ctx, apiName, chunk, isLastChunk) if err == nil && modifiedChunk != nil { - // Check if we need to convert OpenAI stream response back to Claude format - // Only convert if we did the forward conversion (provider doesn't support Claude natively) - needClaudeConversion, _ := ctx.GetContext("needClaudeResponseConversion").(bool) - if needClaudeConversion { - converter := &provider.ClaudeToOpenAIConverter{} - claudeChunk, err := converter.ConvertOpenAIStreamResponseToClaude(ctx, modifiedChunk) - if err != nil { - log.Errorf("failed to convert streaming response to claude format: %v", err) - return modifiedChunk - } - return claudeChunk + // Convert to Claude format if needed + claudeChunk, convertErr := convertStreamingResponseToClaude(ctx, modifiedChunk) + if convertErr != nil { + return modifiedChunk } - return modifiedChunk + return claudeChunk } return chunk } @@ -392,8 +388,8 @@ func onStreamingResponseBody(ctx wrapper.HttpContext, pluginConfig config.Plugin events := provider.ExtractStreamingEvents(ctx, chunk) log.Debugf("[onStreamingResponseBody] %d events received", len(events)) if len(events) == 0 { - // No events are extracted, return the original chunk - return chunk + // No events are extracted, return empty bytes slice + return []byte("") } var responseBuilder strings.Builder for _, event := range events { @@ -409,7 +405,7 @@ func onStreamingResponseBody(ctx wrapper.HttpContext, pluginConfig config.Plugin log.Errorf("[onStreamingResponseBody] failed to process streaming event: %v\n%s", err, chunk) return chunk } - if outputEvents == nil || len(outputEvents) == 0 { + if len(outputEvents) == 0 { responseBuilder.WriteString(event.ToHttpString()) } else { for _, outputEvent := range outputEvents { @@ -420,22 +416,37 @@ func onStreamingResponseBody(ctx wrapper.HttpContext, pluginConfig config.Plugin result := []byte(responseBuilder.String()) - // Check if we need to convert OpenAI stream response back to Claude format - // Only convert if we did the forward conversion (provider doesn't support Claude natively) - needClaudeConversion, _ := ctx.GetContext("needClaudeResponseConversion").(bool) - if needClaudeConversion { - converter := &provider.ClaudeToOpenAIConverter{} - claudeChunk, err := converter.ConvertOpenAIStreamResponseToClaude(ctx, result) - if err != nil { - log.Errorf("failed to convert streaming event response to claude format: %v", err) - return result - } - return claudeChunk + // Convert to Claude format if needed + claudeChunk, convertErr := convertStreamingResponseToClaude(ctx, result) + if convertErr != nil { + return result } + return claudeChunk + } + // If provider doesn't implement any streaming handlers but we need Claude conversion + // First extract complete events from the chunk + events := provider.ExtractStreamingEvents(ctx, chunk) + log.Debugf("[onStreamingResponseBody] %d events received (no handler)", len(events)) + if len(events) == 0 { + // No events are extracted, return empty bytes slice + return []byte("") + } + + // Build response from extracted events (without handler processing) + var responseBuilder strings.Builder + for _, event := range events { + responseBuilder.WriteString(event.ToHttpString()) + } + + result := []byte(responseBuilder.String()) + + // Convert to Claude format if needed + claudeChunk, convertErr := convertStreamingResponseToClaude(ctx, result) + if convertErr != nil { return result } - return chunk + return claudeChunk } func onHttpResponseBody(ctx wrapper.HttpContext, pluginConfig config.PluginConfig, body []byte) types.Action { @@ -448,33 +459,82 @@ func onHttpResponseBody(ctx wrapper.HttpContext, pluginConfig config.PluginConfi log.Debugf("[onHttpResponseBody] provider=%s", activeProvider.GetProviderType()) + var finalBody []byte + if handler, ok := activeProvider.(provider.TransformResponseBodyHandler); ok { apiName, _ := ctx.GetContext(provider.CtxKeyApiName).(provider.ApiName) - body, err := handler.TransformResponseBody(ctx, apiName, body) + transformedBody, err := handler.TransformResponseBody(ctx, apiName, body) if err != nil { _ = util.ErrorHandler("ai-proxy.proc_resp_body_failed", fmt.Errorf("failed to process response body: %v", err)) return types.ActionContinue } + finalBody = transformedBody + } else { + finalBody = body + } - // Check if we need to convert OpenAI response back to Claude format - // Only convert if we did the forward conversion (provider doesn't support Claude natively) - needClaudeConversion, _ := ctx.GetContext("needClaudeResponseConversion").(bool) - if needClaudeConversion { - converter := &provider.ClaudeToOpenAIConverter{} - body, err = converter.ConvertOpenAIResponseToClaude(ctx, body) - if err != nil { - _ = util.ErrorHandler("ai-proxy.convert_resp_to_claude_failed", fmt.Errorf("failed to convert response to claude format: %v", err)) - return types.ActionContinue - } - } + // Convert to Claude format if needed (applies to both branches) + convertedBody, err := convertResponseBodyToClaude(ctx, finalBody) + if err != nil { + _ = util.ErrorHandler("ai-proxy.convert_resp_to_claude_failed", err) + return types.ActionContinue + } - if err = provider.ReplaceResponseBody(body); err != nil { - _ = util.ErrorHandler("ai-proxy.replace_resp_body_failed", fmt.Errorf("failed to replace response body: %v", err)) - } + if err = provider.ReplaceResponseBody(convertedBody); err != nil { + _ = util.ErrorHandler("ai-proxy.replace_resp_body_failed", fmt.Errorf("failed to replace response body: %v", err)) } return types.ActionContinue } +// Helper function to check if Claude response conversion is needed +func needsClaudeResponseConversion(ctx wrapper.HttpContext) bool { + needClaudeConversion, _ := ctx.GetContext("needClaudeResponseConversion").(bool) + return needClaudeConversion +} + +// Helper function to convert OpenAI streaming response to Claude format +func convertStreamingResponseToClaude(ctx wrapper.HttpContext, data []byte) ([]byte, error) { + if !needsClaudeResponseConversion(ctx) { + return data, nil + } + + // Get or create converter instance from context to maintain state + const claudeConverterKey = "claudeConverter" + var converter *provider.ClaudeToOpenAIConverter + + if converterData := ctx.GetContext(claudeConverterKey); converterData != nil { + if c, ok := converterData.(*provider.ClaudeToOpenAIConverter); ok { + converter = c + } + } + + if converter == nil { + converter = &provider.ClaudeToOpenAIConverter{} + ctx.SetContext(claudeConverterKey, converter) + } + + claudeChunk, err := converter.ConvertOpenAIStreamResponseToClaude(ctx, data) + if err != nil { + log.Errorf("failed to convert streaming response to claude format: %v", err) + return data, err + } + return claudeChunk, nil +} + +// Helper function to convert OpenAI response body to Claude format +func convertResponseBodyToClaude(ctx wrapper.HttpContext, body []byte) ([]byte, error) { + if !needsClaudeResponseConversion(ctx) { + return body, nil + } + + converter := &provider.ClaudeToOpenAIConverter{} + convertedBody, err := converter.ConvertOpenAIResponseToClaude(ctx, body) + if err != nil { + return body, fmt.Errorf("failed to convert response to claude format: %v", err) + } + return convertedBody, nil +} + func normalizeOpenAiRequestBody(body []byte) []byte { var err error // Default setting include_usage. diff --git a/plugins/wasm-go/extensions/ai-proxy/provider/claude.go b/plugins/wasm-go/extensions/ai-proxy/provider/claude.go index 0842e8529..64eddff28 100644 --- a/plugins/wasm-go/extensions/ai-proxy/provider/claude.go +++ b/plugins/wasm-go/extensions/ai-proxy/provider/claude.go @@ -36,8 +36,18 @@ type claudeToolChoice struct { } type claudeChatMessage struct { - Role string `json:"role"` - Content any `json:"content"` + Role string `json:"role"` + Content claudeChatMessageContentWr `json:"content"` +} + +// claudeChatMessageContentWr wraps the content to handle both string and array formats +type claudeChatMessageContentWr struct { + // StringValue holds simple text content + StringValue string + // ArrayValue holds multi-modal content + ArrayValue []claudeChatMessageContent + // IsString indicates whether this is a simple string or array + IsString bool } type claudeChatMessageContentSource struct { @@ -49,23 +59,154 @@ type claudeChatMessageContentSource struct { } type claudeChatMessageContent struct { - Type string `json:"type"` - Text string `json:"text,omitempty"` - Source *claudeChatMessageContentSource `json:"source,omitempty"` + Type string `json:"type"` + Text string `json:"text,omitempty"` + Source *claudeChatMessageContentSource `json:"source,omitempty"` + CacheControl map[string]interface{} `json:"cache_control,omitempty"` + // Tool use fields + Id string `json:"id,omitempty"` // For tool_use + Name string `json:"name,omitempty"` // For tool_use + Input map[string]interface{} `json:"input,omitempty"` // For tool_use + // Tool result fields + ToolUseId string `json:"tool_use_id,omitempty"` // For tool_result + Content string `json:"content,omitempty"` // For tool_result } + +// UnmarshalJSON implements custom JSON unmarshaling for claudeChatMessageContentWr +func (ccw *claudeChatMessageContentWr) UnmarshalJSON(data []byte) error { + // Try to unmarshal as string first + var stringValue string + if err := json.Unmarshal(data, &stringValue); err == nil { + ccw.StringValue = stringValue + ccw.IsString = true + return nil + } + + // Try to unmarshal as array of content blocks + var arrayValue []claudeChatMessageContent + if err := json.Unmarshal(data, &arrayValue); err == nil { + ccw.ArrayValue = arrayValue + ccw.IsString = false + return nil + } + + return fmt.Errorf("content field must be either a string or an array of content blocks") +} + +// MarshalJSON implements custom JSON marshaling for claudeChatMessageContentWr +func (ccw claudeChatMessageContentWr) MarshalJSON() ([]byte, error) { + if ccw.IsString { + return json.Marshal(ccw.StringValue) + } + return json.Marshal(ccw.ArrayValue) +} + +// GetStringValue returns the string representation if it's a string, empty string otherwise +func (ccw claudeChatMessageContentWr) GetStringValue() string { + if ccw.IsString { + return ccw.StringValue + } + return "" +} + +// GetArrayValue returns the array representation if it's an array, empty slice otherwise +func (ccw claudeChatMessageContentWr) GetArrayValue() []claudeChatMessageContent { + if !ccw.IsString { + return ccw.ArrayValue + } + return nil +} + +// NewStringContent creates a new wrapper for string content +func NewStringContent(content string) claudeChatMessageContentWr { + return claudeChatMessageContentWr{ + StringValue: content, + IsString: true, + } +} + +// NewArrayContent creates a new wrapper for array content +func NewArrayContent(content []claudeChatMessageContent) claudeChatMessageContentWr { + return claudeChatMessageContentWr{ + ArrayValue: content, + IsString: false, + } +} + +// claudeSystemPrompt represents the system field which can be either a string or an array of text blocks +type claudeSystemPrompt struct { + // Will be set to the string value if system is a simple string + StringValue string + // Will be set to the array value if system is an array of text blocks + ArrayValue []claudeTextGenContent + // Indicates which type this represents + IsArray bool +} + +// UnmarshalJSON implements custom JSON unmarshaling for claudeSystemPrompt +func (csp *claudeSystemPrompt) UnmarshalJSON(data []byte) error { + // Try to unmarshal as string first + var stringValue string + if err := json.Unmarshal(data, &stringValue); err == nil { + csp.StringValue = stringValue + csp.IsArray = false + return nil + } + + // Try to unmarshal as array of text blocks + var arrayValue []claudeTextGenContent + if err := json.Unmarshal(data, &arrayValue); err == nil { + csp.ArrayValue = arrayValue + csp.IsArray = true + return nil + } + + return fmt.Errorf("system field must be either a string or an array of text blocks") +} + +// MarshalJSON implements custom JSON marshaling for claudeSystemPrompt +func (csp claudeSystemPrompt) MarshalJSON() ([]byte, error) { + if csp.IsArray { + return json.Marshal(csp.ArrayValue) + } + return json.Marshal(csp.StringValue) +} + +// String returns the string representation of the system prompt +func (csp claudeSystemPrompt) String() string { + if csp.IsArray { + // Concatenate all text blocks + var parts []string + for _, block := range csp.ArrayValue { + if block.Text != "" { + parts = append(parts, block.Text) + } + } + return strings.Join(parts, "\n") + } + return csp.StringValue +} + +// claudeThinkingConfig represents the thinking configuration for Claude +type claudeThinkingConfig struct { + Type string `json:"type"` + BudgetTokens int `json:"budget_tokens,omitempty"` +} + type claudeTextGenRequest struct { - Model string `json:"model"` - Messages []claudeChatMessage `json:"messages"` - System string `json:"system,omitempty"` - MaxTokens int `json:"max_tokens,omitempty"` - StopSequences []string `json:"stop_sequences,omitempty"` - Stream bool `json:"stream,omitempty"` - Temperature float64 `json:"temperature,omitempty"` - TopP float64 `json:"top_p,omitempty"` - TopK int `json:"top_k,omitempty"` - ToolChoice *claudeToolChoice `json:"tool_choice,omitempty"` - Tools []claudeTool `json:"tools,omitempty"` - ServiceTier string `json:"service_tier,omitempty"` + Model string `json:"model"` + Messages []claudeChatMessage `json:"messages"` + System claudeSystemPrompt `json:"system,omitempty"` + MaxTokens int `json:"max_tokens,omitempty"` + StopSequences []string `json:"stop_sequences,omitempty"` + Stream bool `json:"stream,omitempty"` + Temperature float64 `json:"temperature,omitempty"` + TopP float64 `json:"top_p,omitempty"` + TopK int `json:"top_k,omitempty"` + ToolChoice *claudeToolChoice `json:"tool_choice,omitempty"` + Tools []claudeTool `json:"tools,omitempty"` + ServiceTier string `json:"service_tier,omitempty"` + Thinking *claudeThinkingConfig `json:"thinking,omitempty"` } type claudeTextGenResponse struct { @@ -81,8 +222,13 @@ type claudeTextGenResponse struct { } type claudeTextGenContent struct { - Type string `json:"type,omitempty"` - Text string `json:"text,omitempty"` + Type string `json:"type,omitempty"` + Text string `json:"text,omitempty"` + Id string `json:"id,omitempty"` // For tool_use + Name string `json:"name,omitempty"` // For tool_use + Input map[string]interface{} `json:"input,omitempty"` // For tool_use + Signature string `json:"signature,omitempty"` // For thinking + Thinking string `json:"thinking,omitempty"` // For thinking } type claudeTextGenUsage struct { @@ -99,7 +245,7 @@ type claudeTextGenError struct { type claudeTextGenStreamResponse struct { Type string `json:"type"` Message *claudeTextGenResponse `json:"message,omitempty"` - Index int `json:"index,omitempty"` + Index *int `json:"index,omitempty"` ContentBlock *claudeTextGenContent `json:"content_block,omitempty"` Delta *claudeTextGenDelta `json:"delta,omitempty"` Usage *claudeTextGenUsage `json:"usage,omitempty"` @@ -107,13 +253,13 @@ type claudeTextGenStreamResponse struct { type claudeTextGenDelta struct { Type string `json:"type"` - Text string `json:"text"` - StopReason *string `json:"stop_reason"` - StopSequence *string `json:"stop_sequence"` + Text string `json:"text,omitempty"` + StopReason *string `json:"stop_reason,omitempty"` + StopSequence *string `json:"stop_sequence,omitempty"` } func (c *claudeProviderInitializer) ValidateConfig(config *ProviderConfig) error { - if config.apiTokens == nil || len(config.apiTokens) == 0 { + if len(config.apiTokens) == 0 { return errors.New("no apiToken found in provider config") } return nil @@ -255,7 +401,10 @@ func (c *claudeProvider) buildClaudeTextGenRequest(origRequest *chatCompletionRe for _, message := range origRequest.Messages { if message.Role == roleSystem { - claudeRequest.System = message.StringContent() + claudeRequest.System = claudeSystemPrompt{ + StringValue: message.StringContent(), + IsArray: false, + } continue } @@ -263,7 +412,7 @@ func (c *claudeProvider) buildClaudeTextGenRequest(origRequest *chatCompletionRe Role: message.Role, } if message.IsStringContent() { - claudeMessage.Content = message.StringContent() + claudeMessage.Content = NewStringContent(message.StringContent()) } else { chatMessageContents := make([]claudeChatMessageContent, 0) for _, messageContent := range message.ParseContent() { @@ -310,7 +459,7 @@ func (c *claudeProvider) buildClaudeTextGenRequest(origRequest *chatCompletionRe continue } } - claudeMessage.Content = chatMessageContents + claudeMessage.Content = NewArrayContent(chatMessageContents) } claudeRequest.Messages = append(claudeRequest.Messages, claudeMessage) } @@ -342,19 +491,25 @@ func (c *claudeProvider) responseClaude2OpenAI(ctx wrapper.HttpContext, origResp FinishReason: util.Ptr(stopReasonClaude2OpenAI(origResponse.StopReason)), } - return &chatCompletionResponse{ + response := &chatCompletionResponse{ Id: origResponse.Id, Created: time.Now().UnixMilli() / 1000, Model: ctx.GetStringContext(ctxKeyFinalRequestModel, ""), SystemFingerprint: "", Object: objectChatCompletion, Choices: []chatCompletionChoice{choice}, - Usage: &usage{ + } + + // Include usage information if available + if origResponse.Usage.InputTokens > 0 || origResponse.Usage.OutputTokens > 0 { + response.Usage = &usage{ PromptTokens: origResponse.Usage.InputTokens, CompletionTokens: origResponse.Usage.OutputTokens, TotalTokens: origResponse.Usage.InputTokens + origResponse.Usage.OutputTokens, - }, + } } + + return response } func stopReasonClaude2OpenAI(reason *string) string { @@ -376,31 +531,47 @@ func stopReasonClaude2OpenAI(reason *string) string { func (c *claudeProvider) streamResponseClaude2OpenAI(ctx wrapper.HttpContext, origResponse *claudeTextGenStreamResponse) *chatCompletionResponse { switch origResponse.Type { case "message_start": - c.messageId = origResponse.Message.Id - c.usage = usage{ - PromptTokens: origResponse.Message.Usage.InputTokens, - CompletionTokens: origResponse.Message.Usage.OutputTokens, + if origResponse.Message != nil { + c.messageId = origResponse.Message.Id + c.usage = usage{ + PromptTokens: origResponse.Message.Usage.InputTokens, + CompletionTokens: origResponse.Message.Usage.OutputTokens, + } + c.serviceTier = origResponse.Message.Usage.ServiceTier + } + var index int + if origResponse.Index != nil { + index = *origResponse.Index } - c.serviceTier = origResponse.Message.Usage.ServiceTier choice := chatCompletionChoice{ - Index: origResponse.Index, + Index: index, Delta: &chatMessage{Role: roleAssistant, Content: ""}, } return c.createChatCompletionResponse(ctx, origResponse, choice) case "content_block_delta": + var index int + if origResponse.Index != nil { + index = *origResponse.Index + } choice := chatCompletionChoice{ - Index: origResponse.Index, + Index: index, Delta: &chatMessage{Content: origResponse.Delta.Text}, } return c.createChatCompletionResponse(ctx, origResponse, choice) case "message_delta": - c.usage.CompletionTokens += origResponse.Usage.OutputTokens - c.usage.TotalTokens = c.usage.PromptTokens + c.usage.CompletionTokens + if origResponse.Usage != nil { + c.usage.CompletionTokens += origResponse.Usage.OutputTokens + c.usage.TotalTokens = c.usage.PromptTokens + c.usage.CompletionTokens + } + var index int + if origResponse.Index != nil { + index = *origResponse.Index + } choice := chatCompletionChoice{ - Index: origResponse.Index, + Index: index, Delta: &chatMessage{}, FinishReason: util.Ptr(stopReasonClaude2OpenAI(origResponse.Delta.StopReason)), } @@ -449,10 +620,17 @@ func (c *claudeProvider) insertHttpContextMessage(body []byte, content string, o return nil, fmt.Errorf("unable to unmarshal request: %v", err) } - if request.System == "" { - request.System = content + systemStr := request.System.String() + if systemStr == "" { + request.System = claudeSystemPrompt{ + StringValue: content, + IsArray: false, + } } else { - request.System = content + "\n" + request.System + request.System = claudeSystemPrompt{ + StringValue: content + "\n" + systemStr, + IsArray: false, + } } return json.Marshal(request) diff --git a/plugins/wasm-go/extensions/ai-proxy/provider/claude_to_openai.go b/plugins/wasm-go/extensions/ai-proxy/provider/claude_to_openai.go index 30a814dfa..b9a3030e0 100644 --- a/plugins/wasm-go/extensions/ai-proxy/provider/claude_to_openai.go +++ b/plugins/wasm-go/extensions/ai-proxy/provider/claude_to_openai.go @@ -10,10 +10,49 @@ import ( ) // ClaudeToOpenAIConverter converts Claude protocol requests to OpenAI protocol -type ClaudeToOpenAIConverter struct{} +type ClaudeToOpenAIConverter struct { + // State tracking for streaming conversion + messageStartSent bool + messageStopSent bool + messageId string + // Cache stop_reason until we get usage info + pendingStopReason *string + // Content block tracking with dynamic index allocation + nextContentIndex int + thinkingBlockIndex int + thinkingBlockStarted bool + thinkingBlockStopped bool + textBlockIndex int + textBlockStarted bool + textBlockStopped bool + toolBlockIndex int + toolBlockStarted bool + toolBlockStopped bool + // Tool call state tracking + toolCallStates map[string]*toolCallState +} + +// contentConversionResult represents the result of converting Claude content to OpenAI format +type contentConversionResult struct { + textParts []string + toolCalls []toolCall + toolResults []claudeChatMessageContent + openaiContents []chatMessageContent + hasNonTextContent bool +} + +// toolCallState tracks the state of a tool call during streaming +type toolCallState struct { + id string + name string + argumentsBuffer string + isComplete bool +} // ConvertClaudeRequestToOpenAI converts a Claude chat completion request to OpenAI format func (c *ClaudeToOpenAIConverter) ConvertClaudeRequestToOpenAI(body []byte) ([]byte, error) { + log.Debugf("[Claude->OpenAI] Original Claude request body: %s", string(body)) + var claudeRequest claudeTextGenRequest if err := json.Unmarshal(body, &claudeRequest); err != nil { return nil, fmt.Errorf("unable to unmarshal claude request: %v", err) @@ -31,58 +70,74 @@ func (c *ClaudeToOpenAIConverter) ConvertClaudeRequestToOpenAI(body []byte) ([]b // Convert messages from Claude format to OpenAI format for _, claudeMsg := range claudeRequest.Messages { - openaiMsg := chatMessage{ - Role: claudeMsg.Role, - } - - // Handle different content types - switch content := claudeMsg.Content.(type) { - case string: + // Handle different content types using the type-safe wrapper + if claudeMsg.Content.IsString { // Simple text content - openaiMsg.Content = content - case []claudeChatMessageContent: - // Multi-modal content - var openaiContents []chatMessageContent - for _, claudeContent := range content { - switch claudeContent.Type { - case "text": - openaiContents = append(openaiContents, chatMessageContent{ - Type: contentTypeText, - Text: claudeContent.Text, - }) - case "image": - if claudeContent.Source != nil { - if claudeContent.Source.Type == "base64" { - // Convert base64 image to OpenAI format - dataUrl := fmt.Sprintf("data:%s;base64,%s", claudeContent.Source.MediaType, claudeContent.Source.Data) - openaiContents = append(openaiContents, chatMessageContent{ - Type: contentTypeImageUrl, - ImageUrl: &chatMessageContentImageUrl{ - Url: dataUrl, - }, - }) - } else if claudeContent.Source.Type == "url" { - openaiContents = append(openaiContents, chatMessageContent{ - Type: contentTypeImageUrl, - ImageUrl: &chatMessageContentImageUrl{ - Url: claudeContent.Source.Url, - }, - }) - } + openaiMsg := chatMessage{ + Role: claudeMsg.Role, + Content: claudeMsg.Content.GetStringValue(), + } + openaiRequest.Messages = append(openaiRequest.Messages, openaiMsg) + } else { + // Multi-modal content - process with convertContentArray + conversionResult := c.convertContentArray(claudeMsg.Content.GetArrayValue()) + + // Handle tool calls if present + if len(conversionResult.toolCalls) > 0 { + // Use tool_calls format (current OpenAI standard) + openaiMsg := chatMessage{ + Role: claudeMsg.Role, + ToolCalls: conversionResult.toolCalls, + } + + // Add text content if present, otherwise set to null + if len(conversionResult.textParts) > 0 { + openaiMsg.Content = strings.Join(conversionResult.textParts, "\n\n") + } else { + openaiMsg.Content = nil + } + + openaiRequest.Messages = append(openaiRequest.Messages, openaiMsg) + } + + // Handle tool results if present + if len(conversionResult.toolResults) > 0 { + for _, toolResult := range conversionResult.toolResults { + toolMsg := chatMessage{ + Role: "tool", + Content: toolResult.Content, + ToolCallId: toolResult.ToolUseId, } + openaiRequest.Messages = append(openaiRequest.Messages, toolMsg) } } - openaiMsg.Content = openaiContents - } - openaiRequest.Messages = append(openaiRequest.Messages, openaiMsg) + // Handle regular content if no tool calls or tool results + if len(conversionResult.toolCalls) == 0 && len(conversionResult.toolResults) == 0 { + var content interface{} + if !conversionResult.hasNonTextContent && len(conversionResult.textParts) > 0 { + // Simple text content + content = strings.Join(conversionResult.textParts, "\n\n") + } else { + // Multi-modal content or empty content + content = conversionResult.openaiContents + } + + openaiMsg := chatMessage{ + Role: claudeMsg.Role, + Content: content, + } + openaiRequest.Messages = append(openaiRequest.Messages, openaiMsg) + } + } } // Handle system message - Claude has separate system field - if claudeRequest.System != "" { + systemStr := claudeRequest.System.String() + if systemStr != "" { systemMsg := chatMessage{ Role: roleSystem, - Content: claudeRequest.System, + Content: systemStr, } // Insert system message at the beginning openaiRequest.Messages = append([]chatMessage{systemMsg}, openaiRequest.Messages...) @@ -119,11 +174,44 @@ func (c *ClaudeToOpenAIConverter) ConvertClaudeRequestToOpenAI(body []byte) ([]b openaiRequest.ParallelToolCalls = !claudeRequest.ToolChoice.DisableParallelToolUse } - return json.Marshal(openaiRequest) + // Convert thinking configuration if present + if claudeRequest.Thinking != nil { + log.Debugf("[Claude->OpenAI] Found thinking config: type=%s, budget_tokens=%d", + claudeRequest.Thinking.Type, claudeRequest.Thinking.BudgetTokens) + + if claudeRequest.Thinking.Type == "enabled" { + openaiRequest.ReasoningMaxTokens = claudeRequest.Thinking.BudgetTokens + + // Set ReasoningEffort based on budget_tokens + // low: <4096, medium: >=4096 and <16384, high: >=16384 + if claudeRequest.Thinking.BudgetTokens < 4096 { + openaiRequest.ReasoningEffort = "low" + } else if claudeRequest.Thinking.BudgetTokens < 16384 { + openaiRequest.ReasoningEffort = "medium" + } else { + openaiRequest.ReasoningEffort = "high" + } + + log.Debugf("[Claude->OpenAI] Converted thinking config: budget_tokens=%d, reasoning_effort=%s, reasoning_max_tokens=%d", + claudeRequest.Thinking.BudgetTokens, openaiRequest.ReasoningEffort, openaiRequest.ReasoningMaxTokens) + } + } else { + log.Debugf("[Claude->OpenAI] No thinking config found") + } + + result, err := json.Marshal(openaiRequest) + if err != nil { + return nil, fmt.Errorf("unable to marshal openai request: %v", err) + } + + log.Debugf("[Claude->OpenAI] Converted OpenAI request body: %s", string(result)) + return result, nil } // ConvertOpenAIResponseToClaude converts an OpenAI response back to Claude format func (c *ClaudeToOpenAIConverter) ConvertOpenAIResponseToClaude(ctx wrapper.HttpContext, body []byte) ([]byte, error) { + log.Debugf("[OpenAI->Claude] Original OpenAI response body: %s", string(body)) + var openaiResponse chatCompletionResponse if err := json.Unmarshal(body, &openaiResponse); err != nil { return nil, fmt.Errorf("unable to unmarshal openai response: %v", err) @@ -135,21 +223,73 @@ func (c *ClaudeToOpenAIConverter) ConvertOpenAIResponseToClaude(ctx wrapper.Http Type: "message", Role: "assistant", Model: openaiResponse.Model, - Usage: claudeTextGenUsage{ + } + + // Only include usage if it's available + if openaiResponse.Usage != nil { + claudeResponse.Usage = claudeTextGenUsage{ InputTokens: openaiResponse.Usage.PromptTokens, OutputTokens: openaiResponse.Usage.CompletionTokens, - }, + } } // Convert the first choice content if len(openaiResponse.Choices) > 0 { choice := openaiResponse.Choices[0] if choice.Message != nil { - content := claudeTextGenContent{ - Type: "text", - Text: choice.Message.StringContent(), + var contents []claudeTextGenContent + + // Add reasoning content (thinking) if present - check both reasoning and reasoning_content fields + var reasoningText string + if choice.Message.Reasoning != "" { + reasoningText = choice.Message.Reasoning + } else if choice.Message.ReasoningContent != "" { + reasoningText = choice.Message.ReasoningContent } - claudeResponse.Content = []claudeTextGenContent{content} + + if reasoningText != "" { + contents = append(contents, claudeTextGenContent{ + Type: "thinking", + Signature: "", // OpenAI doesn't provide signature, use empty string + Thinking: reasoningText, + }) + log.Debugf("[OpenAI->Claude] Added thinking content: %s", reasoningText) + } + + // Add text content if present + if choice.Message.StringContent() != "" { + contents = append(contents, claudeTextGenContent{ + Type: "text", + Text: choice.Message.StringContent(), + }) + } + + // Add tool calls if present + if len(choice.Message.ToolCalls) > 0 { + for _, toolCall := range choice.Message.ToolCalls { + if !toolCall.Function.IsEmpty() { + // Parse arguments from JSON string to map + var input map[string]interface{} + if toolCall.Function.Arguments != "" { + if err := json.Unmarshal([]byte(toolCall.Function.Arguments), &input); err != nil { + log.Errorf("Failed to parse tool call arguments: %v", err) + input = map[string]interface{}{} + } + } else { + input = map[string]interface{}{} + } + + contents = append(contents, claudeTextGenContent{ + Type: "tool_use", + Id: toolCall.Id, + Name: toolCall.Function.Name, + Input: input, + }) + } + } + } + + claudeResponse.Content = contents } // Convert finish reason @@ -159,11 +299,24 @@ func (c *ClaudeToOpenAIConverter) ConvertOpenAIResponseToClaude(ctx wrapper.Http } } - return json.Marshal(claudeResponse) + result, err := json.Marshal(claudeResponse) + if err != nil { + return nil, fmt.Errorf("unable to marshal claude response: %v", err) + } + + log.Debugf("[OpenAI->Claude] Converted Claude response body: %s", string(result)) + return result, nil } // ConvertOpenAIStreamResponseToClaude converts OpenAI streaming response to Claude format func (c *ClaudeToOpenAIConverter) ConvertOpenAIStreamResponseToClaude(ctx wrapper.HttpContext, chunk []byte) ([]byte, error) { + log.Debugf("[OpenAI->Claude] Original OpenAI streaming chunk: %s", string(chunk)) + + // Initialize tool call states if needed + if c.toolCallStates == nil { + c.toolCallStates = make(map[string]*toolCallState) + } + // For streaming responses, we need to handle the Server-Sent Events format lines := strings.Split(string(chunk), "\n") var result strings.Builder @@ -172,88 +325,413 @@ func (c *ClaudeToOpenAIConverter) ConvertOpenAIStreamResponseToClaude(ctx wrappe if strings.HasPrefix(line, "data: ") { data := strings.TrimPrefix(line, "data: ") - // Skip [DONE] messages + // Handle [DONE] messages if data == "[DONE]" { + log.Debugf("[OpenAI->Claude] Processing [DONE] message, finalizing stream") + + // Send final content_block_stop events for any active blocks + if c.thinkingBlockStarted && !c.thinkingBlockStopped { + c.thinkingBlockStopped = true + log.Debugf("[OpenAI->Claude] Sending final thinking content_block_stop event at index %d", c.thinkingBlockIndex) + stopEvent := &claudeTextGenStreamResponse{ + Type: "content_block_stop", + Index: &c.thinkingBlockIndex, + } + stopData, _ := json.Marshal(stopEvent) + result.WriteString(fmt.Sprintf("data: %s\n\n", stopData)) + } + if c.textBlockStarted && !c.textBlockStopped { + c.textBlockStopped = true + log.Debugf("[OpenAI->Claude] Sending final text content_block_stop event at index %d", c.textBlockIndex) + stopEvent := &claudeTextGenStreamResponse{ + Type: "content_block_stop", + Index: &c.textBlockIndex, + } + stopData, _ := json.Marshal(stopEvent) + result.WriteString(fmt.Sprintf("data: %s\n\n", stopData)) + } + if c.toolBlockStarted && !c.toolBlockStopped { + c.toolBlockStopped = true + log.Debugf("[OpenAI->Claude] Sending final tool content_block_stop event at index %d", c.toolBlockIndex) + stopEvent := &claudeTextGenStreamResponse{ + Type: "content_block_stop", + Index: &c.toolBlockIndex, + } + stopData, _ := json.Marshal(stopEvent) + result.WriteString(fmt.Sprintf("data: %s\n\n", stopData)) + } + + // If we have a pending stop_reason but no usage, send message_delta with just stop_reason + if c.pendingStopReason != nil { + log.Debugf("[OpenAI->Claude] Sending final message_delta with pending stop_reason: %s", *c.pendingStopReason) + messageDelta := &claudeTextGenStreamResponse{ + Type: "message_delta", + Delta: &claudeTextGenDelta{ + Type: "message_delta", + StopReason: c.pendingStopReason, + }, + } + stopData, _ := json.Marshal(messageDelta) + result.WriteString(fmt.Sprintf("data: %s\n\n", stopData)) + c.pendingStopReason = nil + } + + if c.messageStartSent && !c.messageStopSent { + c.messageStopSent = true + log.Debugf("[OpenAI->Claude] Sending final message_stop event") + messageStopEvent := &claudeTextGenStreamResponse{ + Type: "message_stop", + } + stopData, _ := json.Marshal(messageStopEvent) + result.WriteString(fmt.Sprintf("data: %s\n\n", stopData)) + } + + // Reset all state for next request + c.messageStartSent = false + c.messageStopSent = false + c.messageId = "" + c.pendingStopReason = nil + c.nextContentIndex = 0 + c.thinkingBlockIndex = -1 + c.thinkingBlockStarted = false + c.thinkingBlockStopped = false + c.textBlockIndex = -1 + c.textBlockStarted = false + c.textBlockStopped = false + c.toolBlockIndex = -1 + c.toolBlockStarted = false + c.toolBlockStopped = false + c.toolCallStates = make(map[string]*toolCallState) + log.Debugf("[OpenAI->Claude] Reset converter state for next request") + continue } var openaiStreamResponse chatCompletionResponse if err := json.Unmarshal([]byte(data), &openaiStreamResponse); err != nil { - log.Errorf("unable to unmarshal openai stream response: %v", err) + log.Debugf("unable to unmarshal openai stream response: %v, data: %s", err, data) continue } // Convert to Claude streaming format - claudeStreamResponse := c.buildClaudeStreamResponse(ctx, &openaiStreamResponse) - if claudeStreamResponse != nil { + claudeStreamResponses := c.buildClaudeStreamResponse(ctx, &openaiStreamResponse) + log.Debugf("[OpenAI->Claude] Generated %d Claude stream events from OpenAI chunk", len(claudeStreamResponses)) + + for i, claudeStreamResponse := range claudeStreamResponses { responseData, err := json.Marshal(claudeStreamResponse) if err != nil { log.Errorf("unable to marshal claude stream response: %v", err) continue } + log.Debugf("[OpenAI->Claude] Stream event [%d/%d]: %s", i+1, len(claudeStreamResponses), string(responseData)) result.WriteString(fmt.Sprintf("data: %s\n\n", responseData)) } } } - return []byte(result.String()), nil + claudeChunk := []byte(result.String()) + log.Debugf("[OpenAI->Claude] Converted Claude streaming chunk: %s", string(claudeChunk)) + return claudeChunk, nil } -// buildClaudeStreamResponse builds a Claude streaming response from OpenAI streaming response -func (c *ClaudeToOpenAIConverter) buildClaudeStreamResponse(ctx wrapper.HttpContext, openaiResponse *chatCompletionResponse) *claudeTextGenStreamResponse { +// buildClaudeStreamResponse builds Claude streaming responses from OpenAI streaming response +func (c *ClaudeToOpenAIConverter) buildClaudeStreamResponse(ctx wrapper.HttpContext, openaiResponse *chatCompletionResponse) []*claudeTextGenStreamResponse { if len(openaiResponse.Choices) == 0 { + log.Debugf("[OpenAI->Claude] No choices in OpenAI response, skipping") return nil } choice := openaiResponse.Choices[0] + var responses []*claudeTextGenStreamResponse - // Determine the response type based on the content - if choice.Delta != nil && choice.Delta.Content != "" { - // Content delta - if deltaContent, ok := choice.Delta.Content.(string); ok { - return &claudeTextGenStreamResponse{ - Type: "content_block_delta", - Index: choice.Index, - Delta: &claudeTextGenDelta{ - Type: "text_delta", - Text: deltaContent, - }, + // Log what we're processing + hasRole := choice.Delta != nil && choice.Delta.Role != "" + hasContent := choice.Delta != nil && choice.Delta.Content != "" + hasFinishReason := choice.FinishReason != nil + hasUsage := openaiResponse.Usage != nil + + log.Debugf("[OpenAI->Claude] Processing OpenAI chunk - Role: %v, Content: %v, FinishReason: %v, Usage: %v", + hasRole, hasContent, hasFinishReason, hasUsage) + + // Handle message start (only once) + // Note: OpenRouter may send multiple messages with role but empty content at the start + // We only send message_start for the first one + if choice.Delta != nil && choice.Delta.Role != "" && !c.messageStartSent { + c.messageId = openaiResponse.Id + c.messageStartSent = true + + message := &claudeTextGenResponse{ + Id: openaiResponse.Id, + Type: "message", + Role: "assistant", + Model: openaiResponse.Model, + Content: []claudeTextGenContent{}, + } + + // Only include usage if it's available + if openaiResponse.Usage != nil { + message.Usage = claudeTextGenUsage{ + InputTokens: openaiResponse.Usage.PromptTokens, + OutputTokens: 0, } } - } else if choice.FinishReason != nil { - // Message completed - claudeFinishReason := openAIFinishReasonToClaude(*choice.FinishReason) - return &claudeTextGenStreamResponse{ - Type: "message_delta", - Index: choice.Index, + + responses = append(responses, &claudeTextGenStreamResponse{ + Type: "message_start", + Message: message, + }) + + log.Debugf("[OpenAI->Claude] Generated message_start event for id: %s", openaiResponse.Id) + } else if choice.Delta != nil && choice.Delta.Role != "" && c.messageStartSent { + // Skip duplicate role messages from OpenRouter + log.Debugf("[OpenAI->Claude] Skipping duplicate role message for id: %s", openaiResponse.Id) + } + + // Handle reasoning content (thinking) first - check both reasoning and reasoning_content fields + var reasoningText string + if choice.Delta != nil { + if choice.Delta.Reasoning != "" { + reasoningText = choice.Delta.Reasoning + } else if choice.Delta.ReasoningContent != "" { + reasoningText = choice.Delta.ReasoningContent + } + } + + if reasoningText != "" { + log.Debugf("[OpenAI->Claude] Processing reasoning content delta: %s", reasoningText) + + // Send content_block_start for thinking only once with dynamic index + if !c.thinkingBlockStarted { + c.thinkingBlockIndex = c.nextContentIndex + c.nextContentIndex++ + c.thinkingBlockStarted = true + log.Debugf("[OpenAI->Claude] Generated content_block_start event for thinking at index %d", c.thinkingBlockIndex) + responses = append(responses, &claudeTextGenStreamResponse{ + Type: "content_block_start", + Index: &c.thinkingBlockIndex, + ContentBlock: &claudeTextGenContent{ + Type: "thinking", + Signature: "", // OpenAI doesn't provide signature + Thinking: "", + }, + }) + } + + // Send content_block_delta for thinking + log.Debugf("[OpenAI->Claude] Generated content_block_delta event with thinking: %s", reasoningText) + responses = append(responses, &claudeTextGenStreamResponse{ + Type: "content_block_delta", + Index: &c.thinkingBlockIndex, Delta: &claudeTextGenDelta{ - Type: "message_delta", - StopReason: &claudeFinishReason, + Type: "thinking_delta", // Use thinking_delta for reasoning content + Text: reasoningText, + }, + }) + } + + // Handle content + if choice.Delta != nil && choice.Delta.Content != nil && choice.Delta.Content != "" { + deltaContent, ok := choice.Delta.Content.(string) + if !ok { + log.Debugf("[OpenAI->Claude] Content is not a string: %T", choice.Delta.Content) + return responses + } + + log.Debugf("[OpenAI->Claude] Processing content delta: %s", deltaContent) + + // Close thinking content block if it's still open + if c.thinkingBlockStarted && !c.thinkingBlockStopped { + c.thinkingBlockStopped = true + log.Debugf("[OpenAI->Claude] Closing thinking content block before text") + responses = append(responses, &claudeTextGenStreamResponse{ + Type: "content_block_stop", + Index: &c.thinkingBlockIndex, + }) + } + + // Send content_block_start only once for text content with dynamic index + if !c.textBlockStarted { + c.textBlockIndex = c.nextContentIndex + c.nextContentIndex++ + c.textBlockStarted = true + log.Debugf("[OpenAI->Claude] Generated content_block_start event for text at index %d", c.textBlockIndex) + responses = append(responses, &claudeTextGenStreamResponse{ + Type: "content_block_start", + Index: &c.textBlockIndex, + ContentBlock: &claudeTextGenContent{ + Type: "text", + Text: "", + }, + }) + } + + // Send content_block_delta + log.Debugf("[OpenAI->Claude] Generated content_block_delta event with text: %s", deltaContent) + responses = append(responses, &claudeTextGenStreamResponse{ + Type: "content_block_delta", + Index: &c.textBlockIndex, + Delta: &claudeTextGenDelta{ + Type: "text_delta", + Text: deltaContent, + }, + }) + } + + // Handle tool calls in streaming response + if choice.Delta != nil && len(choice.Delta.ToolCalls) > 0 { + for _, toolCall := range choice.Delta.ToolCalls { + if !toolCall.Function.IsEmpty() { + log.Debugf("[OpenAI->Claude] Processing tool call delta") + + // Get or create tool call state + state := c.toolCallStates[toolCall.Id] + if state == nil { + state = &toolCallState{ + id: toolCall.Id, + name: toolCall.Function.Name, + argumentsBuffer: "", + isComplete: false, + } + c.toolCallStates[toolCall.Id] = state + log.Debugf("[OpenAI->Claude] Created new tool call state for id: %s, name: %s", toolCall.Id, toolCall.Function.Name) + } + + // Accumulate arguments + if toolCall.Function.Arguments != "" { + state.argumentsBuffer += toolCall.Function.Arguments + log.Debugf("[OpenAI->Claude] Accumulated tool arguments: %s", state.argumentsBuffer) + } + + // Try to parse accumulated arguments as JSON to check if complete + var input map[string]interface{} + if state.argumentsBuffer != "" { + if err := json.Unmarshal([]byte(state.argumentsBuffer), &input); err == nil { + // Successfully parsed - arguments are complete + if !state.isComplete { + state.isComplete = true + log.Debugf("[OpenAI->Claude] Tool call arguments complete for %s: %s", state.name, state.argumentsBuffer) + + // Close thinking content block if it's still open + if c.thinkingBlockStarted && !c.thinkingBlockStopped { + c.thinkingBlockStopped = true + log.Debugf("[OpenAI->Claude] Closing thinking content block before tool use") + responses = append(responses, &claudeTextGenStreamResponse{ + Type: "content_block_stop", + Index: &c.thinkingBlockIndex, + }) + } + + // Close text content block if it's still open + if c.textBlockStarted && !c.textBlockStopped { + c.textBlockStopped = true + log.Debugf("[OpenAI->Claude] Closing text content block before tool use") + responses = append(responses, &claudeTextGenStreamResponse{ + Type: "content_block_stop", + Index: &c.textBlockIndex, + }) + } + + // Send content_block_start for tool_use only when we have complete arguments with dynamic index + if !c.toolBlockStarted { + c.toolBlockIndex = c.nextContentIndex + c.nextContentIndex++ + c.toolBlockStarted = true + log.Debugf("[OpenAI->Claude] Generated content_block_start event for tool_use at index %d", c.toolBlockIndex) + responses = append(responses, &claudeTextGenStreamResponse{ + Type: "content_block_start", + Index: &c.toolBlockIndex, + ContentBlock: &claudeTextGenContent{ + Type: "tool_use", + Id: toolCall.Id, + Name: state.name, + Input: input, + }, + }) + } + } + } else { + // Still accumulating arguments + log.Debugf("[OpenAI->Claude] Tool arguments not yet complete, continuing to accumulate: %v", err) + } + } + } + } + } + + // Handle finish reason + if choice.FinishReason != nil { + claudeFinishReason := openAIFinishReasonToClaude(*choice.FinishReason) + log.Debugf("[OpenAI->Claude] Processing finish_reason: %s -> %s", *choice.FinishReason, claudeFinishReason) + + // Send content_block_stop for any active content blocks + if c.thinkingBlockStarted && !c.thinkingBlockStopped { + c.thinkingBlockStopped = true + log.Debugf("[OpenAI->Claude] Generated thinking content_block_stop event at index %d", c.thinkingBlockIndex) + responses = append(responses, &claudeTextGenStreamResponse{ + Type: "content_block_stop", + Index: &c.thinkingBlockIndex, + }) + } + if c.textBlockStarted && !c.textBlockStopped { + c.textBlockStopped = true + log.Debugf("[OpenAI->Claude] Generated text content_block_stop event at index %d", c.textBlockIndex) + responses = append(responses, &claudeTextGenStreamResponse{ + Type: "content_block_stop", + Index: &c.textBlockIndex, + }) + } + if c.toolBlockStarted && !c.toolBlockStopped { + c.toolBlockStopped = true + log.Debugf("[OpenAI->Claude] Generated tool content_block_stop event at index %d", c.toolBlockIndex) + responses = append(responses, &claudeTextGenStreamResponse{ + Type: "content_block_stop", + Index: &c.toolBlockIndex, + }) + } + + // Cache stop_reason until we get usage info (Claude protocol requires them together) + c.pendingStopReason = &claudeFinishReason + log.Debugf("[OpenAI->Claude] Cached stop_reason: %s, waiting for usage", claudeFinishReason) + } + + // Handle usage information + if openaiResponse.Usage != nil && choice.FinishReason == nil { + log.Debugf("[OpenAI->Claude] Processing usage info - input: %d, output: %d", + openaiResponse.Usage.PromptTokens, openaiResponse.Usage.CompletionTokens) + + // Send message_delta with both stop_reason and usage (Claude protocol requirement) + messageDelta := &claudeTextGenStreamResponse{ + Type: "message_delta", + Delta: &claudeTextGenDelta{ + Type: "message_delta", }, Usage: &claudeTextGenUsage{ InputTokens: openaiResponse.Usage.PromptTokens, OutputTokens: openaiResponse.Usage.CompletionTokens, }, } - } else if choice.Delta != nil && choice.Delta.Role != "" { - // Message start - return &claudeTextGenStreamResponse{ - Type: "message_start", - Index: choice.Index, - Message: &claudeTextGenResponse{ - Id: openaiResponse.Id, - Type: "message", - Role: "assistant", - Model: openaiResponse.Model, - Usage: claudeTextGenUsage{ - InputTokens: openaiResponse.Usage.PromptTokens, - OutputTokens: 0, - }, - }, + + // Include cached stop_reason if available + if c.pendingStopReason != nil { + log.Debugf("[OpenAI->Claude] Combining cached stop_reason %s with usage", *c.pendingStopReason) + messageDelta.Delta.StopReason = c.pendingStopReason + c.pendingStopReason = nil // Clear cache + } + + log.Debugf("[OpenAI->Claude] Generated message_delta event with usage and stop_reason") + responses = append(responses, messageDelta) + + // Send message_stop after combined message_delta + if !c.messageStopSent { + c.messageStopSent = true + log.Debugf("[OpenAI->Claude] Generated message_stop event") + responses = append(responses, &claudeTextGenStreamResponse{ + Type: "message_stop", + }) } } - return nil + return responses } // openAIFinishReasonToClaude converts OpenAI finish reason to Claude format @@ -269,3 +747,78 @@ func openAIFinishReasonToClaude(reason string) string { return reason } } + +// convertContentArray converts an array of Claude content to OpenAI content format +func (c *ClaudeToOpenAIConverter) convertContentArray(claudeContents []claudeChatMessageContent) *contentConversionResult { + result := &contentConversionResult{ + textParts: []string{}, + toolCalls: []toolCall{}, + toolResults: []claudeChatMessageContent{}, + openaiContents: []chatMessageContent{}, + hasNonTextContent: false, + } + + for _, claudeContent := range claudeContents { + switch claudeContent.Type { + case "text": + if claudeContent.Text != "" { + result.textParts = append(result.textParts, claudeContent.Text) + result.openaiContents = append(result.openaiContents, chatMessageContent{ + Type: contentTypeText, + Text: claudeContent.Text, + }) + } + case "image": + result.hasNonTextContent = true + if claudeContent.Source != nil { + if claudeContent.Source.Type == "base64" { + // Convert base64 image to OpenAI format + dataUrl := fmt.Sprintf("data:%s;base64,%s", claudeContent.Source.MediaType, claudeContent.Source.Data) + result.openaiContents = append(result.openaiContents, chatMessageContent{ + Type: contentTypeImageUrl, + ImageUrl: &chatMessageContentImageUrl{ + Url: dataUrl, + }, + }) + } else if claudeContent.Source.Type == "url" { + result.openaiContents = append(result.openaiContents, chatMessageContent{ + Type: contentTypeImageUrl, + ImageUrl: &chatMessageContentImageUrl{ + Url: claudeContent.Source.Url, + }, + }) + } + } + case "tool_use": + result.hasNonTextContent = true + // Convert Claude tool_use to OpenAI tool_calls format + if claudeContent.Id != "" && claudeContent.Name != "" { + // Convert input to JSON string for OpenAI format + var argumentsStr string + if claudeContent.Input != nil { + if argBytes, err := json.Marshal(claudeContent.Input); err == nil { + argumentsStr = string(argBytes) + } + } + + toolCall := toolCall{ + Id: claudeContent.Id, + Type: "function", + Function: functionCall{ + Name: claudeContent.Name, + Arguments: argumentsStr, + }, + } + result.toolCalls = append(result.toolCalls, toolCall) + log.Debugf("[Claude->OpenAI] Converted tool_use to tool_call: %s", claudeContent.Name) + } + case "tool_result": + result.hasNonTextContent = true + // Store tool results for processing + result.toolResults = append(result.toolResults, claudeContent) + log.Debugf("[Claude->OpenAI] Found tool_result for tool_use_id: %s", claudeContent.ToolUseId) + } + } + + return result +} diff --git a/plugins/wasm-go/extensions/ai-proxy/provider/claude_to_openai_test.go b/plugins/wasm-go/extensions/ai-proxy/provider/claude_to_openai_test.go new file mode 100644 index 000000000..2d1a3d062 --- /dev/null +++ b/plugins/wasm-go/extensions/ai-proxy/provider/claude_to_openai_test.go @@ -0,0 +1,727 @@ +package provider + +import ( + "encoding/json" + "testing" + + "github.com/higress-group/wasm-go/pkg/log" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// Mock logger for testing +type mockLogger struct{} + +func (m *mockLogger) Trace(msg string) {} +func (m *mockLogger) Tracef(format string, args ...interface{}) {} +func (m *mockLogger) Debug(msg string) {} +func (m *mockLogger) Debugf(format string, args ...interface{}) {} +func (m *mockLogger) Info(msg string) {} +func (m *mockLogger) Infof(format string, args ...interface{}) {} +func (m *mockLogger) Warn(msg string) {} +func (m *mockLogger) Warnf(format string, args ...interface{}) {} +func (m *mockLogger) Error(msg string) {} +func (m *mockLogger) Errorf(format string, args ...interface{}) {} +func (m *mockLogger) Critical(msg string) {} +func (m *mockLogger) Criticalf(format string, args ...interface{}) {} +func (m *mockLogger) ResetID(pluginID string) {} + +func init() { + // Initialize mock logger for testing + log.SetPluginLog(&mockLogger{}) +} + +func TestClaudeToOpenAIConverter_ConvertClaudeRequestToOpenAI(t *testing.T) { + converter := &ClaudeToOpenAIConverter{} + + t.Run("convert_multiple_text_content_blocks", func(t *testing.T) { + // Test case for the bug fix: multiple text content blocks should be merged into a single string + claudeRequest := `{ + "max_tokens": 32000, + "messages": [{ + "content": [{ + "text": "\nThis is a reminder that your todo list is currently empty. DO NOT mention this to the user explicitly because they are already aware. If you are working on tasks that would benefit from a todo list please use the TodoWrite tool to create one. If not, please feel free to ignore. Again do not mention this message to the user.", + "type": "text" + }, { + "text": "\nyyy", + "type": "text" + }, { + "cache_control": { + "type": "ephemeral" + }, + "text": "你是谁", + "type": "text" + }], + "role": "user" + }], + "metadata": { + "user_id": "user_dd3c52c1d698a4486bdef490197846b7c1f7e553202dae5763f330c35aeb9823_account__session_b2e14122-0ac6-4959-9c5d-b49ae01ccb7c" + }, + "model": "anthropic/claude-sonnet-4", + "stream": true, + "system": [{ + "cache_control": { + "type": "ephemeral" + }, + "text": "xxx", + "type": "text" + }, { + "cache_control": { + "type": "ephemeral" + }, + "text": "yyy", + "type": "text" + }], + "temperature": 1, + "stream_options": { + "include_usage": true + } + }` + + result, err := converter.ConvertClaudeRequestToOpenAI([]byte(claudeRequest)) + require.NoError(t, err) + + // Parse the result to verify the conversion + var openaiRequest chatCompletionRequest + err = json.Unmarshal(result, &openaiRequest) + require.NoError(t, err) + + // Verify basic fields are converted correctly + assert.Equal(t, "anthropic/claude-sonnet-4", openaiRequest.Model) + assert.Equal(t, true, openaiRequest.Stream) + assert.Equal(t, 1.0, openaiRequest.Temperature) + assert.Equal(t, 32000, openaiRequest.MaxTokens) + + // Verify messages structure + require.Len(t, openaiRequest.Messages, 2) + + // First message should be system message (converted from Claude's system field) + systemMsg := openaiRequest.Messages[0] + assert.Equal(t, roleSystem, systemMsg.Role) + assert.Equal(t, "xxx\nyyy", systemMsg.Content) // Claude system uses single \n + + // Second message should be user message with merged text content + userMsg := openaiRequest.Messages[1] + assert.Equal(t, "user", userMsg.Role) + + // The key fix: multiple text blocks should be merged into a single string + expectedContent := "\nThis is a reminder that your todo list is currently empty. DO NOT mention this to the user explicitly because they are already aware. If you are working on tasks that would benefit from a todo list please use the TodoWrite tool to create one. If not, please feel free to ignore. Again do not mention this message to the user.\n\n\nyyy\n\n你是谁" + assert.Equal(t, expectedContent, userMsg.Content) + }) + + t.Run("convert_mixed_content_with_image", func(t *testing.T) { + // Test case with mixed text and image content (should remain as array) + claudeRequest := `{ + "model": "claude-3-sonnet-20240229", + "messages": [{ + "role": "user", + "content": [{ + "type": "text", + "text": "What's in this image?" + }, { + "type": "image", + "source": { + "type": "base64", + "media_type": "image/jpeg", + "data": "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==" + } + }] + }], + "max_tokens": 1000 + }` + + result, err := converter.ConvertClaudeRequestToOpenAI([]byte(claudeRequest)) + require.NoError(t, err) + + var openaiRequest chatCompletionRequest + err = json.Unmarshal(result, &openaiRequest) + require.NoError(t, err) + + // Should have one user message + require.Len(t, openaiRequest.Messages, 1) + userMsg := openaiRequest.Messages[0] + assert.Equal(t, "user", userMsg.Role) + + // Content should be an array (mixed content) - after JSON marshaling/unmarshaling it becomes []interface{} + contentArray, ok := userMsg.Content.([]interface{}) + require.True(t, ok, "Content should be an array for mixed content") + require.Len(t, contentArray, 2) + + // First element should be text + firstElement, ok := contentArray[0].(map[string]interface{}) + require.True(t, ok) + assert.Equal(t, contentTypeText, firstElement["type"]) + assert.Equal(t, "What's in this image?", firstElement["text"]) + + // Second element should be image + secondElement, ok := contentArray[1].(map[string]interface{}) + require.True(t, ok) + assert.Equal(t, contentTypeImageUrl, secondElement["type"]) + assert.NotNil(t, secondElement["image_url"]) + imageUrl, ok := secondElement["image_url"].(map[string]interface{}) + require.True(t, ok) + assert.Contains(t, imageUrl["url"], "data:image/jpeg;base64,") + }) + + t.Run("convert_simple_string_content", func(t *testing.T) { + // Test case with simple string content + claudeRequest := `{ + "model": "claude-3-sonnet-20240229", + "messages": [{ + "role": "user", + "content": "Hello, how are you?" + }], + "max_tokens": 1000 + }` + + result, err := converter.ConvertClaudeRequestToOpenAI([]byte(claudeRequest)) + require.NoError(t, err) + + var openaiRequest chatCompletionRequest + err = json.Unmarshal(result, &openaiRequest) + require.NoError(t, err) + + require.Len(t, openaiRequest.Messages, 1) + userMsg := openaiRequest.Messages[0] + assert.Equal(t, "user", userMsg.Role) + assert.Equal(t, "Hello, how are you?", userMsg.Content) + }) + + t.Run("convert_empty_content_array", func(t *testing.T) { + // Test case with empty content array + claudeRequest := `{ + "model": "claude-3-sonnet-20240229", + "messages": [{ + "role": "user", + "content": [] + }], + "max_tokens": 1000 + }` + + result, err := converter.ConvertClaudeRequestToOpenAI([]byte(claudeRequest)) + require.NoError(t, err) + + var openaiRequest chatCompletionRequest + err = json.Unmarshal(result, &openaiRequest) + require.NoError(t, err) + + require.Len(t, openaiRequest.Messages, 1) + userMsg := openaiRequest.Messages[0] + assert.Equal(t, "user", userMsg.Role) + + // Empty array should result in empty array, not string - after JSON marshaling/unmarshaling becomes []interface{} + if userMsg.Content != nil { + contentArray, ok := userMsg.Content.([]interface{}) + require.True(t, ok, "Empty content should be an array") + assert.Empty(t, contentArray) + } else { + // null is also acceptable for empty content + assert.Nil(t, userMsg.Content) + } + }) + + t.Run("convert_tool_use_to_tool_calls", func(t *testing.T) { + // Test Claude tool_use conversion to OpenAI tool_calls format + claudeRequest := `{ + "model": "anthropic/claude-sonnet-4", + "messages": [{ + "role": "assistant", + "content": [{ + "type": "text", + "text": "I'll help you search for information." + }, { + "type": "tool_use", + "id": "toolu_01D7FLrfh4GYq7yT1ULFeyMV", + "name": "web_search", + "input": { + "query": "Claude AI capabilities", + "max_results": 5 + } + }] + }], + "max_tokens": 1000 + }` + + result, err := converter.ConvertClaudeRequestToOpenAI([]byte(claudeRequest)) + require.NoError(t, err) + + var openaiRequest chatCompletionRequest + err = json.Unmarshal(result, &openaiRequest) + require.NoError(t, err) + + // Should have one assistant message with tool_calls + require.Len(t, openaiRequest.Messages, 1) + assistantMsg := openaiRequest.Messages[0] + assert.Equal(t, "assistant", assistantMsg.Role) + assert.Equal(t, "I'll help you search for information.", assistantMsg.Content) + + // Verify tool_calls format + require.NotNil(t, assistantMsg.ToolCalls) + require.Len(t, assistantMsg.ToolCalls, 1) + + toolCall := assistantMsg.ToolCalls[0] + assert.Equal(t, "toolu_01D7FLrfh4GYq7yT1ULFeyMV", toolCall.Id) + assert.Equal(t, "function", toolCall.Type) + assert.Equal(t, "web_search", toolCall.Function.Name) + + // Verify arguments are properly JSON encoded + var args map[string]interface{} + err = json.Unmarshal([]byte(toolCall.Function.Arguments), &args) + require.NoError(t, err) + assert.Equal(t, "Claude AI capabilities", args["query"]) + assert.Equal(t, float64(5), args["max_results"]) + }) + + t.Run("convert_tool_result_to_tool_message", func(t *testing.T) { + // Test Claude tool_result conversion to OpenAI tool message format + claudeRequest := `{ + "model": "anthropic/claude-sonnet-4", + "messages": [{ + "role": "user", + "content": [{ + "type": "tool_result", + "tool_use_id": "toolu_01D7FLrfh4GYq7yT1ULFeyMV", + "content": "Search results: Claude is an AI assistant created by Anthropic." + }] + }], + "max_tokens": 1000 + }` + + result, err := converter.ConvertClaudeRequestToOpenAI([]byte(claudeRequest)) + require.NoError(t, err) + + var openaiRequest chatCompletionRequest + err = json.Unmarshal(result, &openaiRequest) + require.NoError(t, err) + + // Should have one tool message + require.Len(t, openaiRequest.Messages, 1) + toolMsg := openaiRequest.Messages[0] + assert.Equal(t, "tool", toolMsg.Role) + assert.Equal(t, "Search results: Claude is an AI assistant created by Anthropic.", toolMsg.Content) + assert.Equal(t, "toolu_01D7FLrfh4GYq7yT1ULFeyMV", toolMsg.ToolCallId) + }) + + t.Run("convert_multiple_tool_calls", func(t *testing.T) { + // Test multiple tool_use in single message + claudeRequest := `{ + "model": "anthropic/claude-sonnet-4", + "messages": [{ + "role": "assistant", + "content": [{ + "type": "tool_use", + "id": "toolu_search", + "name": "web_search", + "input": {"query": "weather"} + }, { + "type": "tool_use", + "id": "toolu_calc", + "name": "calculate", + "input": {"expression": "2+2"} + }] + }], + "max_tokens": 1000 + }` + + result, err := converter.ConvertClaudeRequestToOpenAI([]byte(claudeRequest)) + require.NoError(t, err) + + var openaiRequest chatCompletionRequest + err = json.Unmarshal(result, &openaiRequest) + require.NoError(t, err) + + // Should have one assistant message with multiple tool_calls + require.Len(t, openaiRequest.Messages, 1) + assistantMsg := openaiRequest.Messages[0] + assert.Equal(t, "assistant", assistantMsg.Role) + assert.Nil(t, assistantMsg.Content) // No text content, so should be null + + // Verify multiple tool_calls + require.NotNil(t, assistantMsg.ToolCalls) + require.Len(t, assistantMsg.ToolCalls, 2) + + // First tool call + assert.Equal(t, "toolu_search", assistantMsg.ToolCalls[0].Id) + assert.Equal(t, "web_search", assistantMsg.ToolCalls[0].Function.Name) + + // Second tool call + assert.Equal(t, "toolu_calc", assistantMsg.ToolCalls[1].Id) + assert.Equal(t, "calculate", assistantMsg.ToolCalls[1].Function.Name) + }) + + t.Run("convert_multiple_tool_results", func(t *testing.T) { + // Test multiple tool_result messages + claudeRequest := `{ + "model": "anthropic/claude-sonnet-4", + "messages": [{ + "role": "user", + "content": [{ + "type": "tool_result", + "tool_use_id": "toolu_search", + "content": "Weather: 25°C sunny" + }, { + "type": "tool_result", + "tool_use_id": "toolu_calc", + "content": "Result: 4" + }] + }], + "max_tokens": 1000 + }` + + result, err := converter.ConvertClaudeRequestToOpenAI([]byte(claudeRequest)) + require.NoError(t, err) + + var openaiRequest chatCompletionRequest + err = json.Unmarshal(result, &openaiRequest) + require.NoError(t, err) + + // Should have two tool messages + require.Len(t, openaiRequest.Messages, 2) + + // First tool result + toolMsg1 := openaiRequest.Messages[0] + assert.Equal(t, "tool", toolMsg1.Role) + assert.Equal(t, "Weather: 25°C sunny", toolMsg1.Content) + assert.Equal(t, "toolu_search", toolMsg1.ToolCallId) + + // Second tool result + toolMsg2 := openaiRequest.Messages[1] + assert.Equal(t, "tool", toolMsg2.Role) + assert.Equal(t, "Result: 4", toolMsg2.Content) + assert.Equal(t, "toolu_calc", toolMsg2.ToolCallId) + }) + + t.Run("convert_mixed_text_and_tool_use", func(t *testing.T) { + // Test message with both text and tool_use + claudeRequest := `{ + "model": "anthropic/claude-sonnet-4", + "messages": [{ + "role": "assistant", + "content": [{ + "type": "text", + "text": "Let me search for that information and do a calculation." + }, { + "type": "tool_use", + "id": "toolu_search123", + "name": "search_database", + "input": {"table": "users", "limit": 10} + }] + }], + "max_tokens": 1000 + }` + + result, err := converter.ConvertClaudeRequestToOpenAI([]byte(claudeRequest)) + require.NoError(t, err) + + var openaiRequest chatCompletionRequest + err = json.Unmarshal(result, &openaiRequest) + require.NoError(t, err) + + // Should have one assistant message with both content and tool_calls + require.Len(t, openaiRequest.Messages, 1) + assistantMsg := openaiRequest.Messages[0] + assert.Equal(t, "assistant", assistantMsg.Role) + assert.Equal(t, "Let me search for that information and do a calculation.", assistantMsg.Content) + + // Should have tool_calls + require.NotNil(t, assistantMsg.ToolCalls) + require.Len(t, assistantMsg.ToolCalls, 1) + assert.Equal(t, "toolu_search123", assistantMsg.ToolCalls[0].Id) + assert.Equal(t, "search_database", assistantMsg.ToolCalls[0].Function.Name) + }) +} + +func TestClaudeToOpenAIConverter_ConvertOpenAIResponseToClaude(t *testing.T) { + converter := &ClaudeToOpenAIConverter{} + + t.Run("convert_tool_calls_response", func(t *testing.T) { + // Test OpenAI response with tool calls conversion to Claude format + openaiResponse := `{ + "id": "gen-1756214072-tVFkPBV6lxee00IqNAC5", + "provider": "Google", + "model": "anthropic/claude-sonnet-4", + "object": "chat.completion", + "created": 1756214072, + "choices": [{ + "logprobs": null, + "finish_reason": "tool_calls", + "native_finish_reason": "tool_calls", + "index": 0, + "message": { + "role": "assistant", + "content": "I'll analyze the README file to understand this project's purpose.", + "refusal": null, + "reasoning": null, + "tool_calls": [{ + "id": "toolu_vrtx_017ijjgx8hpigatPzzPW59Wq", + "index": 0, + "type": "function", + "function": { + "name": "Read", + "arguments": "{\"file_path\": \"/Users/zhangty/git/higress/README.md\"}" + } + }] + } + }], + "usage": { + "prompt_tokens": 14923, + "completion_tokens": 81, + "total_tokens": 15004 + } + }` + + result, err := converter.ConvertOpenAIResponseToClaude(nil, []byte(openaiResponse)) + require.NoError(t, err) + + var claudeResponse claudeTextGenResponse + err = json.Unmarshal(result, &claudeResponse) + require.NoError(t, err) + + // Verify basic response fields + assert.Equal(t, "gen-1756214072-tVFkPBV6lxee00IqNAC5", claudeResponse.Id) + assert.Equal(t, "message", claudeResponse.Type) + assert.Equal(t, "assistant", claudeResponse.Role) + assert.Equal(t, "anthropic/claude-sonnet-4", claudeResponse.Model) + assert.Equal(t, "tool_use", *claudeResponse.StopReason) + + // Verify usage + assert.Equal(t, 14923, claudeResponse.Usage.InputTokens) + assert.Equal(t, 81, claudeResponse.Usage.OutputTokens) + + // Verify content array has both text and tool_use + require.Len(t, claudeResponse.Content, 2) + + // First content should be text + textContent := claudeResponse.Content[0] + assert.Equal(t, "text", textContent.Type) + assert.Equal(t, "I'll analyze the README file to understand this project's purpose.", textContent.Text) + + // Second content should be tool_use + toolContent := claudeResponse.Content[1] + assert.Equal(t, "tool_use", toolContent.Type) + assert.Equal(t, "toolu_vrtx_017ijjgx8hpigatPzzPW59Wq", toolContent.Id) + assert.Equal(t, "Read", toolContent.Name) + + // Verify tool arguments + require.NotNil(t, toolContent.Input) + assert.Equal(t, "/Users/zhangty/git/higress/README.md", toolContent.Input["file_path"]) + }) +} + +func TestClaudeToOpenAIConverter_ConvertThinkingConfig(t *testing.T) { + converter := &ClaudeToOpenAIConverter{} + + tests := []struct { + name string + claudeRequest string + expectedMaxTokens int + expectedEffort string + expectThinkingConfig bool + }{ + { + name: "thinking_enabled_low", + claudeRequest: `{ + "model": "claude-sonnet-4", + "max_tokens": 1000, + "messages": [{"role": "user", "content": "Hello"}], + "thinking": {"type": "enabled", "budget_tokens": 2048} + }`, + expectedMaxTokens: 2048, + expectedEffort: "low", + expectThinkingConfig: true, + }, + { + name: "thinking_enabled_medium", + claudeRequest: `{ + "model": "claude-sonnet-4", + "max_tokens": 1000, + "messages": [{"role": "user", "content": "Hello"}], + "thinking": {"type": "enabled", "budget_tokens": 8192} + }`, + expectedMaxTokens: 8192, + expectedEffort: "medium", + expectThinkingConfig: true, + }, + { + name: "thinking_enabled_high", + claudeRequest: `{ + "model": "claude-sonnet-4", + "max_tokens": 1000, + "messages": [{"role": "user", "content": "Hello"}], + "thinking": {"type": "enabled", "budget_tokens": 20480} + }`, + expectedMaxTokens: 20480, + expectedEffort: "high", + expectThinkingConfig: true, + }, + { + name: "thinking_disabled", + claudeRequest: `{ + "model": "claude-sonnet-4", + "max_tokens": 1000, + "messages": [{"role": "user", "content": "Hello"}], + "thinking": {"type": "disabled"} + }`, + expectedMaxTokens: 0, + expectedEffort: "", + expectThinkingConfig: false, + }, + { + name: "no_thinking", + claudeRequest: `{ + "model": "claude-sonnet-4", + "max_tokens": 1000, + "messages": [{"role": "user", "content": "Hello"}] + }`, + expectedMaxTokens: 0, + expectedEffort: "", + expectThinkingConfig: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result, err := converter.ConvertClaudeRequestToOpenAI([]byte(tt.claudeRequest)) + assert.NoError(t, err) + assert.NotNil(t, result) + + var openaiRequest chatCompletionRequest + err = json.Unmarshal(result, &openaiRequest) + assert.NoError(t, err) + + if tt.expectThinkingConfig { + assert.Equal(t, tt.expectedMaxTokens, openaiRequest.ReasoningMaxTokens) + assert.Equal(t, tt.expectedEffort, openaiRequest.ReasoningEffort) + } else { + assert.Equal(t, 0, openaiRequest.ReasoningMaxTokens) + assert.Equal(t, "", openaiRequest.ReasoningEffort) + } + }) + } +} + +func TestClaudeToOpenAIConverter_ConvertReasoningResponseToClaude(t *testing.T) { + converter := &ClaudeToOpenAIConverter{} + + tests := []struct { + name string + openaiResponse string + expectThinking bool + expectedText string + }{ + { + name: "response_with_reasoning_content", + openaiResponse: `{ + "id": "chatcmpl-test123", + "object": "chat.completion", + "created": 1699999999, + "model": "gpt-4o", + "choices": [{ + "index": 0, + "message": { + "role": "assistant", + "content": "Based on my analysis, the answer is 42.", + "reasoning_content": "Let me think about this step by step:\n1. The question asks about the meaning of life\n2. According to Douglas Adams, the answer is 42\n3. Therefore, 42 is the correct answer" + }, + "finish_reason": "stop" + }], + "usage": { + "prompt_tokens": 10, + "completion_tokens": 20, + "total_tokens": 30 + } + }`, + expectThinking: true, + expectedText: "Based on my analysis, the answer is 42.", + }, + { + name: "response_with_reasoning_field", + openaiResponse: `{ + "id": "chatcmpl-test789", + "object": "chat.completion", + "created": 1699999999, + "model": "gpt-4o", + "choices": [{ + "index": 0, + "message": { + "role": "assistant", + "content": "Based on my analysis, the answer is 42.", + "reasoning": "Let me think about this step by step:\n1. The question asks about the meaning of life\n2. According to Douglas Adams, the answer is 42\n3. Therefore, 42 is the correct answer" + }, + "finish_reason": "stop" + }], + "usage": { + "prompt_tokens": 10, + "completion_tokens": 20, + "total_tokens": 30 + } + }`, + expectThinking: true, + expectedText: "Based on my analysis, the answer is 42.", + }, + { + name: "response_without_reasoning_content", + openaiResponse: `{ + "id": "chatcmpl-test456", + "object": "chat.completion", + "created": 1699999999, + "model": "gpt-4o", + "choices": [{ + "index": 0, + "message": { + "role": "assistant", + "content": "The answer is 42." + }, + "finish_reason": "stop" + }], + "usage": { + "prompt_tokens": 5, + "completion_tokens": 10, + "total_tokens": 15 + } + }`, + expectThinking: false, + expectedText: "The answer is 42.", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result, err := converter.ConvertOpenAIResponseToClaude(nil, []byte(tt.openaiResponse)) + assert.NoError(t, err) + assert.NotNil(t, result) + + var claudeResponse claudeTextGenResponse + err = json.Unmarshal(result, &claudeResponse) + assert.NoError(t, err) + + // Verify response structure + assert.Equal(t, "message", claudeResponse.Type) + assert.Equal(t, "assistant", claudeResponse.Role) + assert.NotEmpty(t, claudeResponse.Id) // ID should be present + + if tt.expectThinking { + // Should have both thinking and text content + assert.Len(t, claudeResponse.Content, 2) + + // First should be thinking + thinkingContent := claudeResponse.Content[0] + assert.Equal(t, "thinking", thinkingContent.Type) + assert.Equal(t, "", thinkingContent.Signature) // OpenAI doesn't provide signature + assert.Contains(t, thinkingContent.Thinking, "Let me think about this step by step") + + // Second should be text + textContent := claudeResponse.Content[1] + assert.Equal(t, "text", textContent.Type) + assert.Equal(t, tt.expectedText, textContent.Text) + } else { + // Should only have text content + assert.Len(t, claudeResponse.Content, 1) + + textContent := claudeResponse.Content[0] + assert.Equal(t, "text", textContent.Type) + assert.Equal(t, tt.expectedText, textContent.Text) + } + }) + } +} diff --git a/plugins/wasm-go/extensions/ai-proxy/provider/minimax.go b/plugins/wasm-go/extensions/ai-proxy/provider/minimax.go index 9f0825c76..9889f11f0 100644 --- a/plugins/wasm-go/extensions/ai-proxy/provider/minimax.go +++ b/plugins/wasm-go/extensions/ai-proxy/provider/minimax.go @@ -8,10 +8,10 @@ import ( "strings" "github.com/alibaba/higress/plugins/wasm-go/extensions/ai-proxy/util" - "github.com/higress-group/wasm-go/pkg/log" - "github.com/higress-group/wasm-go/pkg/wrapper" "github.com/higress-group/proxy-wasm-go-sdk/proxywasm" "github.com/higress-group/proxy-wasm-go-sdk/proxywasm/types" + "github.com/higress-group/wasm-go/pkg/log" + "github.com/higress-group/wasm-go/pkg/wrapper" "github.com/tidwall/gjson" "github.com/tidwall/sjson" ) diff --git a/plugins/wasm-go/extensions/ai-proxy/provider/model.go b/plugins/wasm-go/extensions/ai-proxy/provider/model.go index d11d223a8..2e5ce3068 100644 --- a/plugins/wasm-go/extensions/ai-proxy/provider/model.go +++ b/plugins/wasm-go/extensions/ai-proxy/provider/model.go @@ -29,7 +29,12 @@ const ( reasoningEndTag = "" ) +type NonOpenAIStyleOptions struct { + ReasoningMaxTokens int `json:"reasoning_max_tokens,omitempty"` +} + type chatCompletionRequest struct { + NonOpenAIStyleOptions Messages []chatMessage `json:"messages"` Model string `json:"model"` Store bool `json:"store,omitempty"` @@ -169,7 +174,9 @@ type chatMessage struct { Role string `json:"role,omitempty"` Content any `json:"content,omitempty"` ReasoningContent string `json:"reasoning_content,omitempty"` + Reasoning string `json:"reasoning,omitempty"` // For streaming responses ToolCalls []toolCall `json:"tool_calls,omitempty"` + FunctionCall *functionCall `json:"function_call,omitempty"` // For legacy OpenAI format Refusal string `json:"refusal,omitempty"` ToolCallId string `json:"tool_call_id,omitempty"` } diff --git a/plugins/wasm-go/extensions/ai-proxy/provider/openrouter.go b/plugins/wasm-go/extensions/ai-proxy/provider/openrouter.go new file mode 100644 index 000000000..b11ee6af2 --- /dev/null +++ b/plugins/wasm-go/extensions/ai-proxy/provider/openrouter.go @@ -0,0 +1,117 @@ +package provider + +import ( + "errors" + "net/http" + "strings" + + "github.com/alibaba/higress/plugins/wasm-go/extensions/ai-proxy/util" + "github.com/higress-group/proxy-wasm-go-sdk/proxywasm/types" + "github.com/higress-group/wasm-go/pkg/wrapper" + "github.com/tidwall/gjson" + "github.com/tidwall/sjson" +) + +// openrouterProvider is the provider for OpenRouter service. +const ( + openrouterDomain = "openrouter.ai" + openrouterChatCompletionPath = "/api/v1/chat/completions" + openrouterCompletionPath = "/api/v1/completions" +) + +type openrouterProviderInitializer struct{} + +func (o *openrouterProviderInitializer) ValidateConfig(config *ProviderConfig) error { + if len(config.apiTokens) == 0 { + return errors.New("no apiToken found in provider config") + } + return nil +} + +func (o *openrouterProviderInitializer) DefaultCapabilities() map[string]string { + return map[string]string{ + string(ApiNameChatCompletion): openrouterChatCompletionPath, + string(ApiNameCompletion): openrouterCompletionPath, + } +} + +func (o *openrouterProviderInitializer) CreateProvider(config ProviderConfig) (Provider, error) { + config.setDefaultCapabilities(o.DefaultCapabilities()) + return &openrouterProvider{ + config: config, + contextCache: createContextCache(&config), + }, nil +} + +type openrouterProvider struct { + config ProviderConfig + contextCache *contextCache +} + +func (o *openrouterProvider) GetProviderType() string { + return providerTypeOpenRouter +} + +func (o *openrouterProvider) OnRequestHeaders(ctx wrapper.HttpContext, apiName ApiName) error { + o.config.handleRequestHeaders(o, ctx, apiName) + return nil +} + +func (o *openrouterProvider) OnRequestBody(ctx wrapper.HttpContext, apiName ApiName, body []byte) (types.Action, error) { + if !o.config.isSupportedAPI(apiName) { + return types.ActionContinue, errUnsupportedApiName + } + return o.config.handleRequestBody(o, o.contextCache, ctx, apiName, body) +} + +func (o *openrouterProvider) TransformRequestHeaders(ctx wrapper.HttpContext, apiName ApiName, headers http.Header) { + util.OverwriteRequestPathHeaderByCapability(headers, string(apiName), o.config.capabilities) + util.OverwriteRequestHostHeader(headers, openrouterDomain) + util.OverwriteRequestAuthorizationHeader(headers, "Bearer "+o.config.GetApiTokenInUse(ctx)) + headers.Del("Content-Length") +} + +func (o *openrouterProvider) TransformRequestBody(ctx wrapper.HttpContext, apiName ApiName, body []byte) ([]byte, error) { + if apiName != ApiNameChatCompletion { + return o.config.defaultTransformRequestBody(ctx, apiName, body) + } + + // Check if ReasoningMaxTokens exists in the request body + reasoningMaxTokens := gjson.GetBytes(body, "reasoning_max_tokens") + if !reasoningMaxTokens.Exists() || reasoningMaxTokens.Int() == 0 { + // No reasoning_max_tokens, use default transformation + return o.config.defaultTransformRequestBody(ctx, apiName, body) + } + + // Clear reasoning_effort field if it exists + modifiedBody, err := sjson.DeleteBytes(body, "reasoning_effort") + if err != nil { + // If delete fails, continue with original body + modifiedBody = body + } + + // Set reasoning.max_tokens to the value of reasoning_max_tokens + modifiedBody, err = sjson.SetBytes(modifiedBody, "reasoning.max_tokens", reasoningMaxTokens.Int()) + if err != nil { + return nil, err + } + + // Remove the original reasoning_max_tokens field + modifiedBody, err = sjson.DeleteBytes(modifiedBody, "reasoning_max_tokens") + if err != nil { + return nil, err + } + + // Apply default model mapping + return o.config.defaultTransformRequestBody(ctx, apiName, modifiedBody) +} + +func (o *openrouterProvider) GetApiName(path string) ApiName { + if strings.Contains(path, openrouterChatCompletionPath) { + return ApiNameChatCompletion + } + if strings.Contains(path, openrouterCompletionPath) { + return ApiNameCompletion + } + return "" +} diff --git a/plugins/wasm-go/extensions/ai-proxy/provider/provider.go b/plugins/wasm-go/extensions/ai-proxy/provider/provider.go index 00a887c74..11f69d556 100644 --- a/plugins/wasm-go/extensions/ai-proxy/provider/provider.go +++ b/plugins/wasm-go/extensions/ai-proxy/provider/provider.go @@ -131,6 +131,7 @@ const ( providerTypeDify = "dify" providerTypeBedrock = "bedrock" providerTypeVertex = "vertex" + providerTypeOpenRouter = "openrouter" protocolOpenAI = "openai" protocolOriginal = "original" @@ -209,6 +210,7 @@ var ( providerTypeDify: &difyProviderInitializer{}, providerTypeBedrock: &bedrockProviderInitializer{}, providerTypeVertex: &vertexProviderInitializer{}, + providerTypeOpenRouter: &openrouterProviderInitializer{}, } )