diff --git a/README_FA.md b/README_FA.md index eec0cce0b2..79b5203f02 100644 --- a/README_FA.md +++ b/README_FA.md @@ -1,85 +1,61 @@ -# cliproxyapi++ 🚀 - -[![Go Report Card](https://goreportcard.com/badge/github.com/KooshaPari/cliproxyapi-plusplus)](https://goreportcard.com/report/github.com/KooshaPari/cliproxyapi-plusplus) -[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT) -[![Docker Pulls](https://img.shields.io/docker/pulls/kooshapari/cliproxyapi-plusplus.svg)](https://hub.docker.com/r/kooshapari/cliproxyapi-plusplus) -[![GitHub Release](https://img.shields.io/github/v/release/KooshaPari/cliproxyapi-plusplus)](https://github.com/KooshaPari/cliproxyapi-plusplus/releases) +# CLIProxyAPI Plus [English](README.md) | 中文 -**cliproxyapi++** 是 [CLIProxyAPI](https://github.com/router-for-me/CLIProxyAPI) 的高性能、经过安全加固的终极分支版本。它秉持“纵深防御”的开发理念和“库优先”的架构设计,为多种主流及私有大模型提供 OpenAI 兼容接口,并具备企业级稳定性。 - ---- - -## 🏆 深度对比:`++` 版本的优势 +这是 [CLIProxyAPI](https://github.com/router-for-me/CLIProxyAPI) 的 Plus 版本,在原有基础上增加了第三方供应商的支持。 -为什么选择 **cliproxyapi++** 而不是主线版本?虽然主线版本专注于开源社区的稳定性,但 `++` 版本则是为高并发、生产级环境而设计的,在安全性、自动化生命周期管理和广泛的提供商支持方面具有显著优势。 +所有的第三方供应商支持都由第三方社区维护者提供,CLIProxyAPI 不提供技术支持。如需取得支持,请与对应的社区维护者联系。 -### 📊 功能对比矩阵 +该 Plus 版本的主线功能与主线功能强制同步。 -| 功能特性 | 主线版本 | CLIProxyAPI+ | **cliproxyapi++** | -| :--- | :---: | :---: | :---: | -| **核心代理逻辑** | ✅ | ✅ | ✅ | -| **基础模型支持** | ✅ | ✅ | ✅ | -| **标准 Web UI** | ❌ | ✅ | ✅ | -| **高级认证 (Kiro/Copilot)** | ❌ | ⚠️ | ✅ **(完整支持)** | -| **后台令牌自动刷新** | ❌ | ❌ | ✅ **(自动刷新)** | -| **安全加固** | 基础 | 基础 | ✅ **(企业级)** | -| **频率限制与冷却** | ❌ | ❌ | ✅ **(智能路由)** | -| **发布标签协议** | ❌ | ⚠️ | ✅ **(支持 `v<版本>` 与 `v<版本>-<批次>` 标签流)** | -| **发布自动化流程** | ⚠️ | ⚠️ | ✅ **(标签推送触发 GoReleaser 并刷新发布信息)** | -| **发布批次自动生成** | ⚠️ | ⚠️ | ✅ **(向 `main` 分支推送会触发 `releasebatch --mode create --target main`,自动打批次标签并发布版本)** | -| **发布说明自动生成** | ❌ | ⚠️ | ✅ **(通过 `releasebatch` 聚合提交并更新 release notes)** | -| **签名/信任链** | ❌ | ❌ | ⚠️ **(当前仅有 `checksums.txt`,尚未接入证书签名)** | -| **CLI 登录与配置治理** | ⚠️ | ⚠️ | ✅ **(支持 `--login` 系列与 `--thegent-login=`,并提供 `--setup` 向导)** | -| **核心逻辑复用** | `internal/` | `internal/` | ✅ **(`pkg/llmproxy`)** | -| **CI/CD 流水线** | 基础 | 基础 | ✅ **(签名/多架构)** | +## 与主线版本版本差异 ---- +- 新增 GitHub Copilot 支持(OAuth 登录),由[em4go](https://github.com/em4go/CLIProxyAPI/tree/feature/github-copilot-auth)提供 +- 新增 Kiro (AWS CodeWhisperer) 支持 (OAuth 登录), 由[fuko2935](https://github.com/fuko2935/CLIProxyAPI/tree/feature/kiro-integration)、[Ravens2121](https://github.com/Ravens2121/CLIProxyAPIPlus/)提供 -## 🔍 技术差异与安全加固 +## 新增功能 (Plus 增强版) -### 1. 架构演进:`pkg/llmproxy` -主线版本将核心逻辑保留在 `internal/` 目录下(这会导致外部 Go 项目无法直接导入),而 **cliproxyapi++** 已将整个翻译和代理引擎重构为清晰、公开的 `pkg/llmproxy` 库。 -* **可复用性**: 您可以直接在自己的 Go 应用程序中导入代理逻辑。 -* **解耦**: 实现了配置管理与执行逻辑的严格分离。 +- **OAuth Web 认证**: 基于浏览器的 Kiro OAuth 登录,提供美观的 Web UI +- **请求限流器**: 内置请求限流,防止 API 滥用 +- **后台令牌刷新**: 过期前 10 分钟自动刷新令牌 +- **监控指标**: 请求指标收集,用于监控和调试 +- **设备指纹**: 设备指纹生成,增强安全性 +- **冷却管理**: 智能冷却机制,应对 API 速率限制 +- **用量检查器**: 实时用量监控和配额管理 +- **模型转换器**: 跨供应商的统一模型名称转换 +- **UTF-8 流处理**: 改进的流式响应处理 -### 2. 企业级身份认证与生命周期管理 -* **完整 GitHub Copilot 集成**: 不仅仅是 API 包装。`++` 包含完整的 OAuth 设备流登录、每个凭据的额度追踪以及智能会话管理。 -* **Kiro (AWS CodeWhisperer) 2.0**: 提供定制化的 Web 界面 (`/v0/oauth/kiro`),支持通过浏览器进行 AWS Builder ID 和 Identity Center 登录。 -* **后台令牌刷新**: 专门的后台服务实时监控令牌状态,并在过期前 10 分钟自动刷新,确保智能体任务零停机。 +## Kiro 认证 -### 3. 安全加固(“纵深防御”) -* **路径保护 (Path Guard)**: 定制的 GitHub Action 工作流 (`pr-path-guard`),防止在 PR 过程中对关键的 `internal/translator/` 逻辑进行任何未经授权的修改。 -* **设备指纹**: 生成唯一且不可变的设备标识符,以满足严格的提供商安全检查,防止账号被标记。 -* **加固的 Docker 基础镜像**: 基于经过审计的 Alpine 3.22.0 层构建,仅包含最少软件包,显著降低了潜在的攻击面。 +### 网页端 OAuth 登录 -### 4. 高规模运营支持 -* **智能冷却机制**: 自动化的“冷却”系统可检测提供商端的频率限制,并智能地暂停对特定供应商的请求,同时将流量路由至其他可用节点。 -* **统一模型转换器**: 复杂的映射层,允许您请求 `claude-3-5-sonnet`,而由代理自动处理目标供应商(如 Vertex、AWS、Anthropic 等)的具体协议要求。 +访问 Kiro OAuth 网页认证界面: ---- +``` +http://your-server:8080/v0/oauth/kiro +``` -## 🚀 快速开始 +提供基于浏览器的 Kiro (AWS CodeWhisperer) OAuth 认证流程,支持: +- AWS Builder ID 登录 +- AWS Identity Center (IDC) 登录 +- 从 Kiro IDE 导入令牌 -### 先决条件 -- 已安装 [Docker](https://docs.docker.com/get-docker/) 和 [Docker Compose](https://docs.docker.com/compose/install/) -- 或安装 [Go 1.26+](https://golang.org/dl/) +## Docker 快速部署 -### 一键部署 (Docker) +### 一键部署 ```bash -# 设置部署目录 -mkdir -p ~/cliproxy && cd ~/cliproxy -curl -o config.yaml https://raw.githubusercontent.com/KooshaPari/cliproxyapi-plusplus/main/config.example.yaml +# 创建部署目录 +mkdir -p ~/cli-proxy && cd ~/cli-proxy -# 创建 compose 文件 +# 创建 docker-compose.yml cat > docker-compose.yml << 'EOF' services: - cliproxy: - image: KooshaPari/cliproxyapi-plusplus:latest - container_name: cliproxyapi++ - ports: ["8317:8317"] + cli-proxy-api: + image: eceasy/cli-proxy-api-plus:latest + container_name: cli-proxy-api-plus + ports: + - "8317:8317" volumes: - ./config.yaml:/CLIProxyAPI/config.yaml - ./auths:/root/.cli-proxy-api @@ -87,47 +63,38 @@ services: restart: unless-stopped EOF -docker compose up -d -``` +# 下载示例配置 +curl -o config.yaml https://raw.githubusercontent.com/router-for-me/CLIProxyAPIPlus/main/config.example.yaml ---- - -## 🛠️ 高级用法 +# 拉取并启动 +docker compose pull && docker compose up -d +``` -### 扩展的供应商支持 -`cliproxyapi++` 开箱即用地支持海量模型注册: -* **直接接入**: Claude, Gemini, OpenAI, Mistral, Groq, DeepSeek. -* **聚合器**: OpenRouter, Together AI, Fireworks AI, Novita AI, SiliconFlow. -* **私有协议**: Kiro (AWS), GitHub Copilot, Roo Code, Kilo AI, MiniMax. +### 配置说明 -### API 规范 -代理提供两个主要的 API 表面: -1. **OpenAI 兼容接口**: `/v1/chat/completions` 和 `/v1/models`。 -2. **管理接口**: - * `GET /v0/config`: 查看当前(支持热重载)的配置。 - * `GET /v0/oauth/kiro`: 交互式 Kiro 认证界面。 - * `GET /v0/logs`: 实时日志查看。 +启动前请编辑 `config.yaml`: ---- +```yaml +# 基本配置示例 +server: + port: 8317 -## 🤝 贡献指南 +# 在此添加你的供应商配置 +``` -我们维持严格的质量门禁,以保持项目的“加固”状态: -1. **代码风格**: 必须通过 `golangci-lint` 检查,且无任何警告。 -2. **测试覆盖**: 所有的翻译器逻辑必须包含单元测试。 -3. **治理**: 对 `pkg/` 核心逻辑的修改需要先在 Issue 中进行讨论。 +### 更新到最新版本 -请参阅 **[CONTRIBUTING.md](CONTRIBUTING.md)** 了解更多详情。 +```bash +cd ~/cli-proxy +docker compose pull && docker compose up -d +``` ---- +## 贡献 -## 📜 开源协议 +该项目仅接受第三方供应商支持的 Pull Request。任何非第三方供应商支持的 Pull Request 都将被拒绝。 -本项目根据 MIT 许可证发行。详情请参阅 [LICENSE](LICENSE) 文件。 +如果需要提交任何非第三方供应商支持的 Pull Request,请提交到[主线](https://github.com/router-for-me/CLIProxyAPI)版本。 ---- +## 许可证 -

- 为现代智能体技术栈打造的加固级 AI 基础设施。
- 由社区倾力打造 ❤️ -

+此项目根据 MIT 许可证授权 - 有关详细信息,请参阅 [LICENSE](LICENSE) 文件。 \ No newline at end of file diff --git a/pkg/llmproxy/translator/codex/openai/chat-completions/codex_openai_request.go b/pkg/llmproxy/translator/codex/openai/chat-completions/codex_openai_request.go index 5e3783319a..a343f24ea9 100644 --- a/pkg/llmproxy/translator/codex/openai/chat-completions/codex_openai_request.go +++ b/pkg/llmproxy/translator/codex/openai/chat-completions/codex_openai_request.go @@ -53,9 +53,11 @@ func ConvertOpenAIRequestToCodex(modelName string, inputRawJSON []byte, stream b // out, _ = sjson.Set(out, "max_output_tokens", v.Value()) // } - // Map reasoning effort; fall back to variant if reasoning_effort missing. + // Map reasoning effort; support flat legacy field and variant fallback. if v := gjson.GetBytes(rawJSON, "reasoning_effort"); v.Exists() { out, _ = sjson.Set(out, "reasoning.effort", v.Value()) + } else if v := gjson.GetBytes(rawJSON, `reasoning\.effort`); v.Exists() { + out, _ = sjson.Set(out, "reasoning.effort", v.Value()) } else if v := gjson.GetBytes(rawJSON, "variant"); v.Exists() { effort := strings.ToLower(strings.TrimSpace(v.String())) if effort == "" { diff --git a/pkg/llmproxy/translator/codex/openai/chat-completions/codex_openai_request_test.go b/pkg/llmproxy/translator/codex/openai/chat-completions/codex_openai_request_test.go index e65ce441f7..1cd689c16c 100644 --- a/pkg/llmproxy/translator/codex/openai/chat-completions/codex_openai_request_test.go +++ b/pkg/llmproxy/translator/codex/openai/chat-completions/codex_openai_request_test.go @@ -141,6 +141,20 @@ func TestConvertOpenAIRequestToCodex_UsesVariantFallbackWhenReasoningEffortMissi } } +func TestConvertOpenAIRequestToCodex_UsesLegacyFlatReasoningEffortField(t *testing.T) { + input := []byte(`{ + "model": "gpt-4o", + "messages": [{"role":"user","content":"hello"}], + "reasoning.effort": "low" + }`) + got := ConvertOpenAIRequestToCodex("gpt-4o", input, false) + res := gjson.ParseBytes(got) + + if gotEffort := res.Get("reasoning.effort").String(); gotEffort != "low" { + t.Fatalf("expected reasoning.effort to use legacy flat field low, got %s", gotEffort) + } +} + func TestConvertOpenAIRequestToCodex_UsesReasoningEffortBeforeVariant(t *testing.T) { input := []byte(`{ "model": "gpt-4o", diff --git a/pkg/llmproxy/translator/openai/openai/responses/openai_openai-responses_request.go b/pkg/llmproxy/translator/openai/openai/responses/openai_openai-responses_request.go index 0be371c33e..b03b3e1cf5 100644 --- a/pkg/llmproxy/translator/openai/openai/responses/openai_openai-responses_request.go +++ b/pkg/llmproxy/translator/openai/openai/responses/openai_openai-responses_request.go @@ -198,11 +198,27 @@ func ConvertOpenAIResponsesRequestToOpenAIChatCompletions(modelName string, inpu } } + // Map reasoning controls. + // + // Priority: + // 1. reasoning.effort object field + // 2. flat legacy field "reasoning.effort" + // 3. variant if reasoningEffort := root.Get("reasoning.effort"); reasoningEffort.Exists() { effort := strings.ToLower(strings.TrimSpace(reasoningEffort.String())) if effort != "" { out, _ = sjson.Set(out, "reasoning_effort", effort) } + } else if reasoningEffort := root.Get(`reasoning\.effort`); reasoningEffort.Exists() { + effort := strings.ToLower(strings.TrimSpace(reasoningEffort.String())) + if effort != "" { + out, _ = sjson.Set(out, "reasoning_effort", effort) + } + } else if variant := root.Get("variant"); variant.Exists() && variant.Type == gjson.String { + effort := strings.ToLower(strings.TrimSpace(variant.String())) + if effort != "" { + out, _ = sjson.Set(out, "reasoning_effort", effort) + } } // Convert tool_choice if present diff --git a/pkg/llmproxy/translator/openai/openai/responses/openai_openai-responses_request_test.go b/pkg/llmproxy/translator/openai/openai/responses/openai_openai-responses_request_test.go index d88a8c886d..3aca4aed60 100644 --- a/pkg/llmproxy/translator/openai/openai/responses/openai_openai-responses_request_test.go +++ b/pkg/llmproxy/translator/openai/openai/responses/openai_openai-responses_request_test.go @@ -159,3 +159,29 @@ func TestConvertOpenAIResponsesRequestToOpenAIChatCompletionsToolChoice(t *testi t.Fatalf("tool_choice should be object, got %s", res.Get("tool_choice").Type.String()) } } + +func TestConvertOpenAIResponsesRequestToOpenAIChatCompletions_MapsLegacyReasoningEffort(t *testing.T) { + input := []byte(`{ + "model":"gpt-4.1", + "input":[{"type":"message","role":"user","content":[{"type":"input_text","text":"ping"}]}], + "reasoning.effort":"low" + }`) + + output := ConvertOpenAIResponsesRequestToOpenAIChatCompletions("gpt-4.1", input, false) + if got := gjson.GetBytes(output, "reasoning_effort").String(); got != "low" { + t.Fatalf("expected reasoning_effort low from legacy flat field, got %q", got) + } +} + +func TestConvertOpenAIResponsesRequestToOpenAIChatCompletions_MapsVariantFallback(t *testing.T) { + input := []byte(`{ + "model":"gpt-4.1", + "input":[{"type":"message","role":"user","content":[{"type":"input_text","text":"ping"}]}], + "variant":"medium" + }`) + + output := ConvertOpenAIResponsesRequestToOpenAIChatCompletions("gpt-4.1", input, false) + if got := gjson.GetBytes(output, "reasoning_effort").String(); got != "medium" { + t.Fatalf("expected reasoning_effort medium from variant, got %q", got) + } +} diff --git a/pkg/llmproxy/translator/openai/openai/responses/openai_openai-responses_response.go b/pkg/llmproxy/translator/openai/openai/responses/openai_openai-responses_response.go index 326cc66d5e..faaafc6cae 100644 --- a/pkg/llmproxy/translator/openai/openai/responses/openai_openai-responses_response.go +++ b/pkg/llmproxy/translator/openai/openai/responses/openai_openai-responses_response.go @@ -12,6 +12,16 @@ import ( "github.com/tidwall/sjson" ) +func pickRequestJSON(originalRequestRawJSON, requestRawJSON []byte) []byte { + if len(originalRequestRawJSON) > 0 && gjson.ValidBytes(originalRequestRawJSON) { + return originalRequestRawJSON + } + if len(requestRawJSON) > 0 && gjson.ValidBytes(requestRawJSON) { + return requestRawJSON + } + return nil +} + type oaiToResponsesStateReasoning struct { ReasoningID string ReasoningData string @@ -488,9 +498,10 @@ func ConvertOpenAIChatCompletionsResponseToOpenAIResponses(ctx context.Context, completed, _ = sjson.Set(completed, "sequence_number", nextSeq()) completed, _ = sjson.Set(completed, "response.id", st.ResponseID) completed, _ = sjson.Set(completed, "response.created_at", st.Created) - // Inject original request fields into response as per docs/response.completed.json - if requestRawJSON != nil { - req := gjson.ParseBytes(requestRawJSON) + // Inject original request fields into response as per docs/response.completed.json. + reqRawJSON := pickRequestJSON(originalRequestRawJSON, requestRawJSON) + if reqRawJSON != nil { + req := gjson.ParseBytes(reqRawJSON) if v := req.Get("instructions"); v.Exists() { completed, _ = sjson.Set(completed, "response.instructions", v.String()) } @@ -664,8 +675,9 @@ func ConvertOpenAIChatCompletionsResponseToOpenAIResponsesNonStream(_ context.Co resp, _ = sjson.Set(resp, "created_at", created) // Echo request fields when available (aligns with streaming path behavior) - if len(requestRawJSON) > 0 { - req := gjson.ParseBytes(requestRawJSON) + reqRawJSON := pickRequestJSON(originalRequestRawJSON, requestRawJSON) + if reqRawJSON != nil { + req := gjson.ParseBytes(reqRawJSON) if v := req.Get("instructions"); v.Exists() { resp, _ = sjson.Set(resp, "instructions", v.String()) } @@ -743,8 +755,8 @@ func ConvertOpenAIChatCompletionsResponseToOpenAIResponsesNonStream(_ context.Co // Detect and capture reasoning content if present rcText := gjson.GetBytes(rawJSON, "choices.0.message.reasoning_content").String() includeReasoning := rcText != "" - if !includeReasoning && len(requestRawJSON) > 0 { - includeReasoning = gjson.GetBytes(requestRawJSON, "reasoning").Exists() + if !includeReasoning && reqRawJSON != nil { + includeReasoning = gjson.GetBytes(reqRawJSON, "reasoning").Exists() } if includeReasoning { rid := strings.TrimPrefix(id, "resp_") diff --git a/pkg/llmproxy/translator/openai/openai/responses/openai_openai-responses_response_test.go b/pkg/llmproxy/translator/openai/openai/responses/openai_openai-responses_response_test.go index 69214bb72f..431fd2a2b2 100644 --- a/pkg/llmproxy/translator/openai/openai/responses/openai_openai-responses_response_test.go +++ b/pkg/llmproxy/translator/openai/openai/responses/openai_openai-responses_response_test.go @@ -166,3 +166,130 @@ func TestConvertOpenAIChatCompletionsResponseToOpenAIResponses_DoneMarkerNoDupli t.Fatalf("expected no events on [DONE] after completion already emitted, got %d", len(doneEvents)) } } + +func extractEventData(event string) string { + lines := strings.SplitN(event, "\n", 2) + if len(lines) != 2 { + return "" + } + return strings.TrimSpace(strings.TrimPrefix(lines[1], "data: ")) +} + +func findCompletedData(outputs []string) string { + for _, output := range outputs { + if strings.HasPrefix(output, "event: response.completed") { + return extractEventData(output) + } + } + return "" +} + +func TestConvertOpenAIChatCompletionsResponseToOpenAIResponsesNonStream_UsesOriginalRequestJSON(t *testing.T) { + original := []byte(`{ + "instructions": "original instructions", + "max_output_tokens": 512, + "model": "orig-model", + "temperature": 0.2 + }`) + request := []byte(`{ + "instructions": "transformed instructions", + "max_output_tokens": 123, + "model": "request-model", + "temperature": 0.9 + }`) + raw := []byte(`{ + "id":"chatcmpl-1", + "created":1700000000, + "model":"gpt-4o-mini", + "choices":[{"index":0,"message":{"content":"hello","role":"assistant"}}], + "usage":{"prompt_tokens":10,"completion_tokens":20,"total_tokens":30} + }`) + + response := ConvertOpenAIChatCompletionsResponseToOpenAIResponsesNonStream(nil, "", original, request, raw, nil) + + if got := gjson.Get(response, "instructions").String(); got != "original instructions" { + t.Fatalf("response.instructions expected original value, got %q", got) + } + if got := gjson.Get(response, "max_output_tokens").Int(); got != 512 { + t.Fatalf("response.max_output_tokens expected original value, got %d", got) + } + if got := gjson.Get(response, "model").String(); got != "orig-model" { + t.Fatalf("response.model expected original value, got %q", got) + } +} + +func TestConvertOpenAIChatCompletionsResponseToOpenAIResponsesNonStream_FallsBackToRequestJSON(t *testing.T) { + request := []byte(`{ + "instructions": "request-only instructions", + "max_output_tokens": 333, + "model": "request-model", + "temperature": 0.8 + }`) + raw := []byte(`{ + "id":"chatcmpl-1", + "created":1700000000, + "model":"gpt-4o-mini", + "choices":[{"index":0,"message":{"content":"hello","role":"assistant"}}], + "usage":{"prompt_tokens":10,"completion_tokens":20,"total_tokens":30} + }`) + + response := ConvertOpenAIChatCompletionsResponseToOpenAIResponsesNonStream(nil, "", nil, request, raw, nil) + + if got := gjson.Get(response, "instructions").String(); got != "request-only instructions" { + t.Fatalf("response.instructions expected request value, got %q", got) + } + if got := gjson.Get(response, "max_output_tokens").Int(); got != 333 { + t.Fatalf("response.max_output_tokens expected request value, got %d", got) + } + if got := gjson.Get(response, "model").String(); got != "request-model" { + t.Fatalf("response.model expected request value, got %q", got) + } +} + +func TestConvertOpenAIChatCompletionsResponseToOpenAIResponses_UsesOriginalRequestJSON(t *testing.T) { + var state any + original := []byte(`{ + "instructions":"stream original", + "max_output_tokens": 512, + "model":"orig-stream-model", + "temperature": 0.4 + }`) + request := []byte(`{ + "instructions":"stream transformed", + "max_output_tokens": 64, + "model":"request-stream-model", + "temperature": 0.9 + }`) + first := []byte(`{ + "id":"chatcmpl-stream", + "created":1700000001, + "object":"chat.completion.chunk", + "choices":[{"index":0,"delta":{"content":"hi"}}] + }`) + second := []byte(`{ + "id":"chatcmpl-stream", + "created":1700000001, + "object":"chat.completion.chunk", + "choices":[{"index":0,"delta":{},"finish_reason":"stop"}] + }`) + + output := ConvertOpenAIChatCompletionsResponseToOpenAIResponses(nil, "", original, request, first, &state) + if len(output) == 0 { + t.Fatal("expected first stream chunk to emit events") + } + output = ConvertOpenAIChatCompletionsResponseToOpenAIResponses(nil, "", original, request, second, &state) + completedData := findCompletedData(output) + if completedData == "" { + t.Fatal("expected response.completed event on final chunk") + } + + if got := gjson.Get(completedData, "response.instructions").String(); got != "stream original" { + t.Fatalf("response.instructions expected original value, got %q", got) + } + if got := gjson.Get(completedData, "response.model").String(); got != "orig-stream-model" { + t.Fatalf("response.model expected original value, got %q", got) + } + if got := gjson.Get(completedData, "response.temperature").Float(); got != 0.4 { + t.Fatalf("response.temperature expected original value, got %f", got) + } +}